fix: Zuordnungen zuverlässig speichern und Mengen-Varianten zusammenfassen
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:
parent
86f15e6957
commit
c1873a47b1
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
253
backend/data_layer/food_knowledge.py
Normal file
253
backend/data_layer/food_knowledge.py
Normal 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,
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
18
backend/migrations/064_food_mapping_units.sql
Normal file
18
backend/migrations/064_food_mapping_units.sql
Normal 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 $$;
|
||||
|
|
@ -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}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
42
backend/tests/test_food_knowledge.py
Normal file
42
backend/tests/test_food_knowledge.py
Normal 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"
|
||||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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'}),
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user