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)
Der Nutzer sucht im **Popup nach dem Namen** (Katalogtreffer zeigen den BLS-Code nur nachrangig). Codes selbst heraussuchen ist nicht vorgesehen.
Der Nutzer sucht im **Popup nach dem Namen** (Katalogtreffer zeigen den BLS-Code nur nachrangig). Codes selbst heraussuchen ist nicht vorgesehen. Fehlt ein Treffer, kann ein **eigener Katalogeintrag** (Name + Makros/100 g) angelegt und sofort zugeordnet werden. Nicht-Gramm-Einheiten (Stück, EL, TL, …) bekommen ein **Gramm-pro-Einheit**-Feld am Mapping.
## FDDB-Listen / eigene Rezepte
FDDB-Tagebuchexport fasst selbst angelegte Listen oft zu **einer Zeile** (Rezeptname + Menge) zusammen. Die Zutaten stehen in einem **separaten Listen-Export** (`lists_*.csv`, Spalte `produkte`). Ablauf: Listen importieren → passende Tagebuchzeilen werden als Rezept verknüpft → **Zutaten** zuordnen, nicht das Rezept als Ganzes. Unvollständige Zutaten-Mappings fallen auf die FDDB-Makros der Tagebuchzeile zurück.
Gelernte Zuordnungen und Listen lassen sich als **JSON sichern** und auf einer anderen Instanz (Dev → Prod) wieder einspielen. Offizielle Lebensmittel werden über den BLS-Code gefunden — der BLS-Katalog muss auf dem Ziel bereits importiert sein.
## Fachliche Regeln
- BLS-Code (`bls_code`, Stoff-`attr_key`) bleibt die stabile Identität bei Reimports.

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
- `GET /api/nutrition/recipes`, `POST /api/nutrition/recipes/import-fddb-lists`, `POST /api/nutrition/recipes/{id}/apply`
- Unmapped = Tagebuchzeilen ohne `food_id`/`recipe_id` **plus** Rezeptzutaten ohne Mapping
- Frontend: `FoodSearchModal` (Name-Suche), Listen-Import auf dem Tab Zuordnen
- Frontend: `FoodSearchModal` (Name-Suche, eigener Eintrag, Gramm/Einheit), Listen-Import auf dem Tab Zuordnen
- Mapping-Schreiben und Nährwert-Rebuild sind getrennte Transaktionen (Rebuild darf das Mapping nicht zurückrollen)
- `food_name_mappings.grams_per_unit` / `source_unit` (Migration **064**)
- `GET/POST /api/nutrition/food-knowledge` — portable JSON (`mitai-food-knowledge` v1): manuelle Foods, Mappings (über `bls_code` / Name, keine UUIDs), Listen. Import löst Katalog auf dem Zielsystem auf (BLS muss dort importiert sein).

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.
- **Admin:** Gruppe Ernährung — BLS-Import, Katalog, Attribute, Mappings.
- **Nutzer:** Einzelerfassung unverändert; Tab Zuordnen mit Namenssuche (Popup); FDDB-Listen/Rezepte; Fasten/Lücke; Import-Abgleich.
- **Nutzer:** Einzelerfassung unverändert; Tab Zuordnen mit Namenssuche (Popup); FDDB-Listen/Rezepte; JSON-Export/Import der Zuordnungen; Fasten/Lücke; Import-Abgleich.
- **Gitea #106:** BLS-Stammdaten, FDDB-Mapping, Item-Tagebuch — http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/106
- **Doku:** `.claude/docs/functional/BLS_FOOD_REFERENCE.md`, `.claude/docs/technical/BLS_FOOD_REFERENCE.md`, `docs/issues/issue-bls-food-mapping.md`. Folge #75.

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)")
def normalize_food_name(raw: str | None) -> str:
def strip_leading_quantity(raw: str | None) -> str:
if not raw:
return ""
s = unicodedata.normalize("NFKC", str(raw)).strip().strip('"').strip("'")
s = s.lstrip("!")
s = LEADING_QTY_RE.sub("", s)
return MULTISPACE_RE.sub(" ", s).strip()
def normalize_food_name(raw: str | None) -> str:
s = strip_leading_quantity(raw)
if not s:
return ""
s = DECIMAL_IN_NAME_RE.sub(r"\1.\2", s)
s = MULTISPACE_RE.sub(" ", s).strip().lower()
return s
return s.lower()
def parse_quantity_g(raw: str | None) -> float | None:
if raw is None or str(raw).strip() == "":
def merge_unmapped_rows(rows: list[dict]) -> list[dict]:
merged: dict[str, dict] = {}
for row in rows:
raw = row.get("source_name_raw") or ""
key = normalize_food_name(raw) or row.get("source_name_normalized") or raw.lower()
if not key:
continue
display = strip_leading_quantity(raw) or raw
count = int(row.get("count") or 0)
if key not in merged:
item = dict(row)
item["source_name_normalized"] = key
item["source_name_raw"] = display
item["count"] = count
item["variant_count"] = 1
merged[key] = item
continue
cur = merged[key]
cur["count"] = int(cur.get("count") or 0) + count
cur["variant_count"] = int(cur.get("variant_count") or 1) + 1
if display and (not cur.get("source_name_raw") or len(display) < len(cur["source_name_raw"])):
cur["source_name_raw"] = display
if row.get("matching_recipe_id") and not cur.get("matching_recipe_id"):
cur["matching_recipe_id"] = row["matching_recipe_id"]
if row.get("sample_quantity_raw") and not cur.get("sample_quantity_raw"):
cur["sample_quantity_raw"] = row["sample_quantity_raw"]
first, last = row.get("first_date"), row.get("last_date")
if first and (not cur.get("first_date") or str(first) < str(cur["first_date"])):
cur["first_date"] = first
if last and (not cur.get("last_date") or str(last) > str(cur["last_date"])):
cur["last_date"] = last
return list(merged.values())
UNIT_ALIASES = {
"g": "g", "gr": "g", "gramm": "g",
"kg": "kg",
"ml": "ml",
"l": "l", "liter": "l", "lt": "l",
"stück": "stück", "stk": "stück", "st": "stück", "st.": "stück", "pcs": "stück",
"el": "el", "esslöffel": "el",
"tl": "tl", "teelöffel": "tl",
"prise": "prise",
"scheibe": "scheibe",
"portion": "portion", "portionen": "portion",
"becher": "becher",
"tasse": "tasse",
"msp": "msp", "msp.": "msp",
}
MASS_VOLUME_TO_G = {"g": 1.0, "kg": 1000.0, "ml": 1.0, "l": 1000.0}
DEFAULT_UNIT_G = {"el": 15.0, "tl": 5.0, "prise": 0.3, "msp": 1.0}
COUNT_UNITS = frozenset({"stück", "scheibe", "portion", "becher", "tasse"})
QTY_PARSE_RE = re.compile(
r"^\s*(\d+(?:[.,]\d+)?)\s*([a-zA-ZäöüÄÖÜß.]+)?\s*$",
re.IGNORECASE,
)
def _canon_unit(raw: str | None) -> str | None:
if not raw:
return None
return UNIT_ALIASES.get(raw.strip().lower().rstrip("."))
def parse_quantity(raw: str | None, grams_per_unit: float | None = None) -> dict[str, Any]:
empty = {"value": None, "unit": None, "quantity_g": None, "needs_unit_map": False}
if raw is None or str(raw).strip() == "":
return empty
text = str(raw).strip().replace(",", ".")
m = re.match(r"^\s*(\d+(?:\.\d+)?)\s*(g|gramm)?\s*$", text, re.IGNORECASE)
if m:
return round(float(m.group(1)), 3)
m = QTY_PARSE_RE.match(text)
if not m:
return empty
value = float(m.group(1))
unit = _canon_unit(m.group(2))
if unit is None:
return {"value": value, "unit": "g", "quantity_g": round(value, 3), "needs_unit_map": False}
if unit in MASS_VOLUME_TO_G:
return {
"value": value,
"unit": unit,
"quantity_g": round(value * MASS_VOLUME_TO_G[unit], 3),
"needs_unit_map": False,
}
factor = grams_per_unit if grams_per_unit is not None else DEFAULT_UNIT_G.get(unit)
if factor is not None:
return {
"value": value,
"unit": unit,
"quantity_g": round(value * float(factor), 3),
"needs_unit_map": unit in COUNT_UNITS,
}
return {"value": value, "unit": unit, "quantity_g": None, "needs_unit_map": True}
def parse_quantity_g(raw: str | None, grams_per_unit: float | None = None) -> float | None:
return parse_quantity(raw, grams_per_unit)["quantity_g"]
def detect_quantity_unit(*texts: str | None) -> str | None:
for text in texts:
unit = parse_quantity(text).get("unit")
if unit and unit not in MASS_VOLUME_TO_G:
return unit
return None
@ -47,6 +149,7 @@ def get_food_mapping_with_cursor(
cur.execute(
"""
SELECT m.id AS mapping_id, m.food_id, m.profile_id, m.source,
m.grams_per_unit, m.source_unit,
f.bls_code, f.name_de, f.catalog_kind
FROM food_name_mappings m
JOIN food_catalog f ON f.id = m.food_id
@ -62,6 +165,7 @@ def get_food_mapping_with_cursor(
cur.execute(
"""
SELECT m.id AS mapping_id, m.food_id, m.profile_id, m.source,
m.grams_per_unit, m.source_unit,
f.bls_code, f.name_de, f.catalog_kind
FROM food_name_mappings m
JOIN food_catalog f ON f.id = m.food_id
@ -83,6 +187,8 @@ def upsert_food_mapping(
profile_id: str | None,
source: str = "bulk",
source_system: str = "fddb",
grams_per_unit: float | None = None,
source_unit: str | None = None,
) -> int:
norm = normalize_food_name(source_name_raw)
if not norm:
@ -105,28 +211,55 @@ def upsert_food_mapping(
)
existing = cur.fetchone()
raw = source_name_raw.strip()
unit = _canon_unit(source_unit) if source_unit else None
if existing:
cur.execute(
"""
UPDATE food_name_mappings
SET food_id = %s, source_name_raw = %s, source = %s, updated_at = NOW()
SET food_id = %s, source_name_raw = %s, source = %s,
grams_per_unit = %s, source_unit = %s, updated_at = NOW()
WHERE id = %s
""",
(food_id, raw, source, existing["id"]),
(food_id, raw, source, grams_per_unit, unit, existing["id"]),
)
return int(existing["id"])
cur.execute(
"""
INSERT INTO food_name_mappings
(source_system, source_name_raw, source_name_normalized, food_id, profile_id, source, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, NOW())
(source_system, source_name_raw, source_name_normalized, food_id, profile_id,
source, grams_per_unit, source_unit, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, NOW())
RETURNING id
""",
(source_system, raw, norm, food_id, profile_id, source),
(source_system, raw, norm, food_id, profile_id, source, grams_per_unit, unit),
)
return int(cur.fetchone()["id"])
def apply_quantities_to_items(cur, profile_id: str, source_name_normalized: str, grams_per_unit: float | None) -> int:
if not grams_per_unit:
return 0
cur.execute(
"""
SELECT id, quantity_raw, source_name_raw
FROM nutrition_items
WHERE profile_id = %s AND source_name_normalized = %s
""",
(profile_id, source_name_normalized),
)
n = 0
for row in cur.fetchall():
qty = parse_quantity_g(row.get("quantity_raw") or row.get("source_name_raw"), grams_per_unit)
if qty is None:
continue
cur.execute(
"UPDATE nutrition_items SET quantity_g = %s, updated_at = NOW() WHERE id = %s",
(qty, row["id"]),
)
n += 1
return n
def apply_mapping_to_items(cur, profile_id: str, source_name_normalized: str, food_id: str, mapping_id: int) -> int:
origin = _value_origin_for_food(cur, food_id)
cur.execute(
@ -138,7 +271,30 @@ def apply_mapping_to_items(cur, profile_id: str, source_name_normalized: str, fo
""",
(food_id, mapping_id, origin, profile_id, source_name_normalized),
)
return cur.rowcount or 0
n = cur.rowcount or 0
cur.execute(
"""
SELECT id, source_name_raw
FROM nutrition_items
WHERE profile_id = %s AND recipe_id IS NULL AND food_id IS NULL
""",
(profile_id,),
)
extra = [
row["id"] for row in cur.fetchall()
if normalize_food_name(row.get("source_name_raw")) == source_name_normalized
]
if extra:
cur.execute(
"""
UPDATE nutrition_items
SET food_id = %s, mapping_id = %s, value_origin = %s, updated_at = NOW()
WHERE id = ANY(%s)
""",
(food_id, mapping_id, origin, extra),
)
n += cur.rowcount or 0
return n
def clear_mapping_from_items(cur, profile_id: str, source_name_normalized: str) -> int:

View File

@ -311,7 +311,10 @@ def replace_csv_items_for_dates(
if not name:
continue
qty_raw = raw.get("quantity_raw")
qty_g = parse_quantity_g(qty_raw if qty_raw is not None else name)
qty_g = parse_quantity_g(
qty_raw if qty_raw is not None else name,
mapping.get("grams_per_unit") if mapping else None,
)
mapping = get_food_mapping_with_cursor(cur, name, profile_id)
logged_at = raw.get("logged_at")
cur.execute(

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."""
from __future__ import annotations
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel
from auth import require_auth
from data_layer.food_mapping import (
apply_mapping_to_items,
apply_quantities_to_items,
clear_mapping_from_items,
normalize_food_name,
suggest_catalog_foods,
@ -18,6 +20,8 @@ from data_layer.nutrition_items import dates_for_normalized_name, rebuild_daily_
from db import get_cursor, get_db, r2d
from routers.profiles import get_pid
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/bls", tags=["bls"])
@ -31,6 +35,17 @@ class MappingUpsert(BaseModel):
source_name: str
food_id: str
source_system: str = "fddb"
grams_per_unit: Optional[float] = None
source_unit: Optional[str] = None
def _pid(session: dict, x_profile_id: Optional[str] = None) -> str:
return x_profile_id or session["profile_id"]
def _rebuild_days(cur, profile_id: str, dates: list[str]) -> None:
for d in dates:
rebuild_daily_nutrients(cur, profile_id, d)
@router.get("/foods")
@ -46,10 +61,15 @@ def search_foods(
@router.post("/foods/manual")
def create_user_food(body: UserFoodCreate, session: dict = Depends(require_auth)):
def create_user_food(
body: UserFoodCreate,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
from routers.admin_bls import _write_manual_macros
import uuid
pid = session["profile_id"]
pid = _pid(session, x_profile_id)
name = (body.name_de or "").strip()
if not name:
raise HTTPException(400, "Name fehlt")
@ -62,7 +82,7 @@ def create_user_food(body: UserFoodCreate, session: dict = Depends(require_auth)
VALUES (%s, %s, 'manual_user', %s, 'manual', %s)
RETURNING *
""",
(name, body.name_en, pid, f"man-user-{normalize_food_name(name)[:40]}"),
(name, body.name_en, pid, f"man-user-{normalize_food_name(name)[:32]}-{uuid.uuid4().hex[:8]}"),
)
food = r2d(cur.fetchone())
_write_manual_macros(cur, food["id"], body.macros_per_100g)
@ -70,13 +90,17 @@ def create_user_food(body: UserFoodCreate, session: dict = Depends(require_auth)
@router.get("/mappings")
def list_my_mappings(session: dict = Depends(require_auth)):
pid = session["profile_id"]
def list_my_mappings(
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
pid = _pid(session, x_profile_id)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT m.id, m.source_name_raw, m.source_name_normalized, m.food_id, m.source,
m.grams_per_unit, m.source_unit,
f.name_de AS food_name_de, f.bls_code, f.catalog_kind
FROM food_name_mappings m
JOIN food_catalog f ON f.id = m.food_id
@ -89,8 +113,12 @@ def list_my_mappings(session: dict = Depends(require_auth)):
@router.post("/mappings")
def upsert_my_mapping(body: MappingUpsert, session: dict = Depends(require_auth)):
pid = session["profile_id"]
def upsert_my_mapping(
body: MappingUpsert,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
pid = _pid(session, x_profile_id)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
@ -110,17 +138,28 @@ def upsert_my_mapping(body: MappingUpsert, session: dict = Depends(require_auth)
profile_id=pid,
source="bulk",
source_system=body.source_system,
grams_per_unit=body.grams_per_unit,
source_unit=body.source_unit,
)
norm = normalize_food_name(body.source_name)
n = apply_mapping_to_items(cur, pid, norm, body.food_id, mid)
for d in dates_for_normalized_name(cur, pid, norm):
rebuild_daily_nutrients(cur, pid, d)
apply_quantities_to_items(cur, pid, norm, body.grams_per_unit)
dates = dates_for_normalized_name(cur, pid, norm)
try:
with get_db() as conn:
_rebuild_days(get_cursor(conn), pid, dates)
except Exception:
logger.exception("Nährwert-Rebuild nach Mapping %s fehlgeschlagen", norm)
return {"mapping_id": mid, "items_updated": n, "source_name_normalized": norm}
@router.delete("/mappings/{mapping_id}")
def delete_my_mapping(mapping_id: int, session: dict = Depends(require_auth)):
pid = session["profile_id"]
def delete_my_mapping(
mapping_id: int,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
pid = _pid(session, x_profile_id)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
@ -137,8 +176,11 @@ def delete_my_mapping(mapping_id: int, session: dict = Depends(require_auth)):
dates = dates_for_normalized_name(cur, pid, norm)
clear_mapping_from_items(cur, pid, norm)
cur.execute("DELETE FROM food_name_mappings WHERE id = %s AND profile_id = %s", (mapping_id, pid))
for d in dates:
rebuild_daily_nutrients(cur, pid, d)
try:
with get_db() as conn:
_rebuild_days(get_cursor(conn), pid, dates)
except Exception:
logger.exception("Nährwert-Rebuild nach Mapping-Löschen fehlgeschlagen")
return {"ok": True}

View File

@ -11,6 +11,7 @@ from typing import Optional
from datetime import datetime
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends
from fastapi.responses import Response
from db import get_db, get_cursor, r2d
from auth import require_auth, check_feature_access, increment_feature_usage
@ -349,14 +350,26 @@ def list_unmapped_foods(
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
pid = get_pid(x_profile_id)
from data_layer.food_mapping import merge_unmapped_rows, normalize_food_name
pid = x_profile_id or session["profile_id"]
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT source_name_normalized
FROM food_name_mappings
WHERE profile_id = %s
""",
(pid,),
)
mapped = {r["source_name_normalized"] for r in cur.fetchall()}
cur.execute(
"""
SELECT i.source_name_raw, i.source_name_normalized,
COUNT(*) AS count, MIN(i.date) AS first_date, MAX(i.date) AS last_date,
MIN(r.id::text) AS matching_recipe_id
MIN(r.id::text) AS matching_recipe_id,
MIN(i.quantity_raw) AS sample_quantity_raw
FROM nutrition_items i
LEFT JOIN food_recipes r
ON r.profile_id = i.profile_id AND r.name_normalized = i.source_name_normalized
@ -370,7 +383,8 @@ def list_unmapped_foods(
cur.execute(
"""
SELECT i.source_name_raw, i.source_name_normalized,
COUNT(*) AS count, NULL::date AS first_date, NULL::date AS last_date
COUNT(*) AS count, NULL::date AS first_date, NULL::date AS last_date,
MIN(i.quantity_raw) AS sample_quantity_raw
FROM food_recipe_ingredients i
JOIN food_recipes r ON r.id = i.recipe_id
LEFT JOIN food_name_mappings m
@ -383,13 +397,15 @@ def list_unmapped_foods(
(pid,),
)
ings = [r2d(r) | {"kind": "recipe_ingredient"} for r in cur.fetchall()]
seen = {d["source_name_normalized"] for d in diary}
for ing in ings:
if ing["source_name_normalized"] not in seen:
diary.append(ing)
seen.add(ing["source_name_normalized"])
diary.sort(key=lambda x: (-int(x.get("count") or 0), x.get("source_name_normalized") or ""))
return diary
merged = merge_unmapped_rows(diary + ings)
out = []
for row in merged:
key = row.get("source_name_normalized") or normalize_food_name(row.get("source_name_raw"))
if key in mapped:
continue
out.append(row)
out.sort(key=lambda x: (-int(x.get("count") or 0), x.get("source_name_normalized") or ""))
return out
@router.get("/recipes")
@ -459,6 +475,50 @@ def apply_recipe_name(
return {"ok": True, "items_updated": n}
@router.get("/food-knowledge")
def export_food_knowledge_file(
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
import json
from data_layer.food_knowledge import export_food_knowledge
pid = get_pid(x_profile_id)
with get_db() as conn:
bundle = export_food_knowledge(get_cursor(conn), pid)
body = json.dumps(bundle, ensure_ascii=False, indent=2, default=str)
stamp = datetime.now().strftime("%Y-%m-%d")
return Response(
content=body.encode("utf-8"),
media_type="application/json; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="mitai-food-knowledge-{stamp}.json"'},
)
@router.post("/food-knowledge")
async def import_food_knowledge_file(
file: UploadFile = File(...),
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
import json
from data_layer.food_knowledge import import_food_knowledge, parse_food_knowledge_bundle
pid = get_pid(x_profile_id)
raw = await file.read()
if not raw:
raise HTTPException(400, "Leere Datei")
try:
data = json.loads(raw.decode("utf-8-sig"))
parse_food_knowledge_bundle(data)
except ValueError as e:
raise HTTPException(400, str(e)) from e
except Exception as e:
raise HTTPException(400, f"Ungültiges JSON: {e}") from e
with get_db() as conn:
return import_food_knowledge(get_cursor(conn), pid, data)
@router.post("/import-conflicts/resolve")
def resolve_import_conflicts(
body: dict,

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 data_layer.food_mapping import normalize_food_name, parse_quantity_g
from data_layer.food_mapping import merge_unmapped_rows, normalize_food_name, parse_quantity, parse_quantity_g
from data_layer.nutrition_items import macros_differ
def test_normalize_strips_leading_quantity():
assert normalize_food_name("50 g Hähnchen") == "hähnchen"
assert normalize_food_name(" Vollmilch 3,5% ") == "vollmilch 3.5%"
assert normalize_food_name("1 ml Olivenöl") == normalize_food_name("2 ml Olivenöl") == "olivenöl"
def test_merge_unmapped_collapses_quantity_variants():
rows = merge_unmapped_rows([
{"source_name_raw": "1 ml Olivenöl", "source_name_normalized": "1 ml olivenöl", "count": 3, "kind": "diary"},
{"source_name_raw": "2 ml Olivenöl", "source_name_normalized": "2 ml olivenöl", "count": 1, "kind": "diary"},
])
assert len(rows) == 1
assert rows[0]["source_name_normalized"] == "olivenöl"
assert rows[0]["count"] == 4
assert rows[0]["source_name_raw"].lower() == "olivenöl"
def test_parse_quantity_g():
assert parse_quantity_g("150 g") == 150.0
assert parse_quantity_g("150") == 150.0
assert parse_quantity_g("1 kg") == 1000.0
assert parse_quantity_g("5 ml") == 5.0
assert parse_quantity_g("1 Stück") is None
assert parse_quantity_g("1 Stück", 60) == 60.0
assert parse_quantity_g("2 EL") == 30.0
assert parse_quantity("1 Scheibe")["needs_unit_map"] is True
assert parse_quantity("2 EL")["unit"] == "el"
def test_guess_fddb_bezeichnung_without_template_mapping():

View File

@ -9,7 +9,7 @@ Semantic Versioning: MAJOR.MINOR.PATCH
APP_VERSION = "0.9v"
BUILD_DATE = "2026-09-12"
DB_SCHEMA_VERSION = "20260912" # 063 FDDB recipes
DB_SCHEMA_VERSION = "20260912" # 064 mapping units
MODULE_VERSIONS = {
"auth": "1.2.0",
@ -20,7 +20,7 @@ MODULE_VERSIONS = {
"circumference": "1.0.1",
"caliper": "1.0.1",
"activity": "1.2.1", # Legacy CSV import: activity_entries feature enforcement
"nutrition": "1.2.0", # FDDB-Listen/Rezepte + Katalog-Suchpopup
"nutrition": "1.2.2", # mapping save + manual food + units
"bls": "1.0.1",
"photos": "1.0.0",
"insights": "1.3.0",
@ -47,6 +47,8 @@ CHANGELOG = [
"BLS-Import als Hintergrundjob (kein Proxy-504)",
"Zuordnen: Katalog-Suche nach Name (Popup), nicht nach BLS-Code",
"FDDB-Listen/Rezepte importieren und Tagebuchzeilen in Zutaten auflösen",
"Zuordnungen und Listen als JSON exportieren/importieren (Dev → Prod)",
"Zuordnen: Mapping unabhängig vom Nährwert-Rebuild; eigener Katalogeintrag; Mengeneinheiten",
],
},
{

View File

@ -16,3 +16,5 @@ Verlässliche Lebensmittel-Stammdaten (BLS 4.0 + manuelle Erweiterung), lernende
- Einzelerfassung nur Makros unverändert
- Zuordnen über **Namenssuche im Popup** (kein BLS-Code-Lookup durch den Nutzer)
- FDDB-Listen-CSV importieren; Tagebuch-Rezeptzeilen in Zutaten auflösen
- Zuordnungen und Listen als JSON zwischen Instanzen übertragen
- Eigener Katalogeintrag im Suchpopup; Gramm pro Stück/EL/… am Mapping

View File

@ -1,14 +1,53 @@
import { useEffect, useRef, useState } from 'react'
import { api } from '../utils/api'
export default function FoodSearchModal({ title, initialQuery, onSelect, onClose }) {
const COUNT_UNITS = new Set(['stück', 'scheibe', 'portion', 'becher', 'tasse'])
const UNIT_LABEL = {
stück: 'Stück', scheibe: 'Scheibe', portion: 'Portion',
becher: 'Becher', tasse: 'Tasse', el: 'EL', tl: 'TL', prise: 'Prise',
}
function detectUnit(...texts) {
for (const text of texts) {
const m = String(text || '').match(
/(\d+(?:[.,]\d+)?)\s*(stück|stk|st\.?|scheibe|portion(?:en)?|becher|tasse|el|esslöffel|tl|teelöffel|prise)\b/i
)
if (!m) continue
const raw = m[2].toLowerCase().replace(/\.$/, '')
const unit = raw.startsWith('st') && !raw.startsWith('scheibe') ? 'stück'
: raw.startsWith('ess') || raw === 'el' ? 'el'
: raw.startsWith('tee') || raw === 'tl' ? 'tl'
: raw.startsWith('portion') ? 'portion'
: raw
return unit
}
return null
}
export default function FoodSearchModal({ title, initialQuery, quantityHint, onSelect, onClose }) {
const [q, setQ] = useState(initialQuery || '')
const [hits, setHits] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [creating, setCreating] = useState(false)
const [showCreate, setShowCreate] = useState(false)
const [manual, setManual] = useState({
name_de: initialQuery || '',
kcal: '', protein_g: '', fat_g: '', carbs_g: '',
})
const suggestedUnit = detectUnit(quantityHint, initialQuery)
const [gramsPerUnit, setGramsPerUnit] = useState(
suggestedUnit === 'el' ? '15' : suggestedUnit === 'tl' ? '5' : ''
)
const inputRef = useRef(null)
const timer = useRef(null)
const extras = () => {
const g = parseFloat(String(gramsPerUnit).replace(',', '.'))
if (!suggestedUnit || !g || g <= 0) return {}
return { grams_per_unit: g, source_unit: suggestedUnit }
}
const runSearch = async (term) => {
const query = (term || '').trim()
if (query.length < 2) {
@ -45,6 +84,34 @@ export default function FoodSearchModal({ title, initialQuery, onSelect, onClose
timer.current = setTimeout(() => runSearch(value), 250)
}
const createManual = async () => {
const name = (manual.name_de || q || '').trim()
if (!name) {
setError('Name für den Eintrag fehlt')
return
}
setCreating(true)
setError(null)
try {
const food = await api.createUserFood({
name_de: name,
macros_per_100g: {
kcal: parseFloat(String(manual.kcal).replace(',', '.')) || 0,
protein_g: parseFloat(String(manual.protein_g).replace(',', '.')) || 0,
fat_g: parseFloat(String(manual.fat_g).replace(',', '.')) || 0,
carbs_g: parseFloat(String(manual.carbs_g).replace(',', '.')) || 0,
},
})
onSelect(food, extras())
} catch (e) {
setError(e.message)
} finally {
setCreating(false)
}
}
const unitLabel = UNIT_LABEL[suggestedUnit] || suggestedUnit
return (
<div
style={{
@ -60,7 +127,7 @@ export default function FoodSearchModal({ title, initialQuery, onSelect, onClose
aria-labelledby="food-search-title"
onClick={(e) => e.stopPropagation()}
style={{
width: '100%', maxWidth: 520, maxHeight: 'min(88vh, 640px)',
width: '100%', maxWidth: 520, maxHeight: 'min(88vh, 720px)',
background: 'var(--surface)', borderRadius: 16,
boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
display: 'flex', flexDirection: 'column',
@ -80,14 +147,30 @@ export default function FoodSearchModal({ title, initialQuery, onSelect, onClose
onChange={(e) => onChange(e.target.value)}
/>
<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>
{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 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>
<p style={{ fontSize: 13, color: 'var(--text3)' }}>Kein Treffer im Katalog.</p>
)}
{hits.map((h) => (
<button
@ -95,7 +178,7 @@ export default function FoodSearchModal({ title, initialQuery, onSelect, onClose
type="button"
className="btn btn-secondary btn-full"
style={{ marginTop: 8, justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '10px 12px' }}
onClick={() => onSelect(h)}
onClick={() => onSelect(h, extras())}
>
<span>
<strong style={{ display: 'block' }}>{h.name_de}</strong>
@ -107,6 +190,53 @@ export default function FoodSearchModal({ title, initialQuery, onSelect, onClose
</span>
</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>

View File

@ -84,31 +84,43 @@ export default function NutritionFoodMap({ onChanged }) {
const [searchFor, setSearchFor] = useState(null)
const [recipeFor, setRecipeFor] = useState(null)
const [importing, setImporting] = useState(false)
const [busy, setBusy] = useState(false)
const listRef = useRef(null)
const bundleRef = useRef(null)
const loadGen = useRef(0)
const load = async () => {
const gen = ++loadGen.current
try {
const [u, m, r] = await Promise.all([
api.listUnmappedFoods(),
api.listMyFoodMappings(),
api.listNutritionRecipes().catch(() => []),
])
if (gen !== loadGen.current) return
setUnmapped(u)
setLearned(m)
setRecipes(Array.isArray(r) ? r : [])
} catch (e) {
if (gen !== loadGen.current) return
setError(e.message)
}
}
useEffect(() => { load() }, [])
const assign = async (sourceName, foodId) => {
const assign = async (sourceName, foodId, extras = {}) => {
setSaving(sourceName)
setError(null)
try {
await api.upsertMyFoodMapping({ source_name: sourceName, food_id: foodId })
await api.upsertMyFoodMapping({
source_name: sourceName,
food_id: foodId,
grams_per_unit: extras.grams_per_unit || null,
source_unit: extras.source_unit || null,
})
setSearchFor(null)
setNotice(`Zuordnung gespeichert: ${sourceName}`)
await load()
onChanged?.()
} catch (e) {
@ -144,6 +156,39 @@ export default function NutritionFoodMap({ onChanged }) {
}
}
const exportBundle = async () => {
setBusy(true)
setError(null)
setNotice(null)
try {
await api.exportFoodKnowledge()
setNotice('Zuordnungen und Listen als JSON heruntergeladen.')
} catch (e) {
setError(e.message)
} finally {
setBusy(false)
}
}
const importBundle = async (file) => {
if (!file) return
setBusy(true)
setError(null)
setNotice(null)
try {
const res = await api.importFoodKnowledge(file)
await load()
onChanged?.()
const skip = res.mappings_skipped ? `, ${res.mappings_skipped} ohne Katalogtreffer` : ''
const lists = (res.inserted || 0) + (res.updated || 0)
setNotice(`${res.mappings || 0} Zuordnungen und ${lists} Listen übernommen${skip}.`)
} catch (e) {
setError(e.message)
} finally {
setBusy(false)
}
}
const importLists = async (file) => {
if (!file) return
setImporting(true)
@ -167,6 +212,7 @@ export default function NutritionFoodMap({ onChanged }) {
<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.
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>
{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>}
@ -193,6 +239,25 @@ export default function NutritionFoodMap({ onChanged }) {
{recipes.length > 0 && (
<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>
{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}`
return (
<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)' }}>
{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}` : ''}
</div>
<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)' }}>
{m.food_name_de}{m.bls_code ? ` · ${m.bls_code}` : ''}
{m.catalog_kind !== 'official_bls' ? ' (manuell)' : ''}
{m.grams_per_unit ? ` · 1 ${m.source_unit || 'Einheit'} = ${m.grams_per_unit} g` : ''}
</div>
</div>
<button type="button" className="btn btn-secondary" onClick={() => remove(m.id)}>Löschen</button>
@ -257,8 +324,9 @@ export default function NutritionFoodMap({ onChanged }) {
<FoodSearchModal
title={`Suchen: ${searchFor.source_name_raw}`}
initialQuery={suggestQuery(searchFor.source_name_raw)}
quantityHint={searchFor.sample_quantity_raw || searchFor.source_name_raw}
onClose={() => setSearchFor(null)}
onSelect={(food) => assign(searchFor.source_name_raw, food.id)}
onSelect={(food, extras) => assign(searchFor.source_name_raw, food.id, extras)}
/>
)}
{recipeFor && (

View File

@ -248,6 +248,27 @@ export const api = {
return readJsonResponse(r)
},
applyNutritionRecipe: (recipeId, sourceName) => req(`/nutrition/recipes/${recipeId}/apply`, json({ source_name: sourceName })),
exportFoodKnowledge: async () => {
const r = await fetch(`${BASE}/nutrition/food-knowledge`, { headers: hdrs() })
if (!r.ok) {
const text = await r.text()
throw new Error(text.trim() || `HTTP ${r.status}`)
}
const blob = await r.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `mitai-food-knowledge-${new Date().toISOString().split('T')[0]}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
},
importFoodKnowledge: async (file) => {
const fd = new FormData(); fd.append('file', file)
const r = await fetch(`${BASE}/nutrition/food-knowledge`, { method: 'POST', body: fd, headers: hdrs() })
return readJsonResponse(r)
},
listNutritionMarks: () => req('/nutrition/marks'),
putNutritionDayMark: (date, d) => req(`/nutrition/days/${date}/mark`, jput(d)),
deleteNutritionDayMark: (date) => req(`/nutrition/days/${date}/mark`, {method:'DELETE'}),

View File

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