diff --git a/.claude/docs/GITEA_ISSUES_INDEX.md b/.claude/docs/GITEA_ISSUES_INDEX.md
index 7c44f43..5074d49 100644
--- a/.claude/docs/GITEA_ISSUES_INDEX.md
+++ b/.claude/docs/GITEA_ISSUES_INDEX.md
@@ -1,6 +1,6 @@
# Gitea Issues – Landkarte (Auswertung)
-**Quelle:** Gitea `Lars/mitai-jinkendo`, Stand **2026-04-11** (Abfrage `state=all`, ergänzt: #71, #76).
+**Quelle:** Gitea `Lars/mitai-jinkendo`, Stand **2026-04-11** (Abfrage `state=all`, ergänzt: #71, #75, #76, #106, 2026-09-12).
**URL:** http://192.168.2.144:3000/Lars/mitai-jinkendo/issues
Dieses Dokument ist ein **Orientierungs-Index** für Agenten und Entwickler. Verbindliches Tracking bleibt **in Gitea**; hier: Kategorien, Dubletten-Hinweise, grobe Prioritätseinschätzung.
@@ -82,6 +82,14 @@ Dieses Dokument ist ein **Orientierungs-Index** für Agenten und Entwickler. Ver
|---|--------|
| 37 | Feature-Enforcement für Activity CSV-Import |
| 38 | Feature-Enforcement für Nutrition CSV-Import UI |
+| 71 | Universal CSV Import: Dry-Run, Mapping-Validierung, Fehler-Hints |
+
+### Ernährung / BLS
+
+| # | Titel |
+|---|--------|
+| 75 | Ernährung: Zucker/Ballaststoffe, Lebensmittelqualität, Timing (Folge nach #106) |
+| 106 | BLS-Stammdaten, FDDB-Mapping und Item-Tagebuch |
### Qualität / Sonstiges
diff --git a/.claude/docs/README.md b/.claude/docs/README.md
index 8699b03..b824cd9 100644
--- a/.claude/docs/README.md
+++ b/.claude/docs/README.md
@@ -56,6 +56,7 @@ _Dieser Ordner `.claude/docs/` ist per `.gitignore`-Ausnahme **versioniert** (Sp
| Dashboard-Widgets | `technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md` | Widget-Katalog + Registrierung (siehe Guide) |
| Training Profiler / Resolver | `technical/TRAINING_PROFILE_RESOLVER_LAYER1.md`, `functional/TRAINING_TYPE_PROFILES.md` | Resolver-Module wie im Guide genannt |
| Universal CSV Import | `technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` | `backend/csv_parser/`, `routers/csv_import.py`, `routers/admin_csv_templates.py` |
+| BLS / Lebensmittel | `functional/BLS_FOOD_REFERENCE.md`, `technical/BLS_FOOD_REFERENCE.md` | Migration 062, `backend/bls/`, `data_layer/food_mapping.py` |
| **Designprinzipien (Produktfamilie)** | **`jinkendo-foundation/design-principles/README.md`** | Querschnittsmuster; Foundation für Schwester-Apps |
| Aktivität Produktionsreife | `technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` (+ EAV-Guide) | `backend/data_layer/activity_session_metrics.py`, `activity_metrics.py`, CSV-Orchestrierung |
| Mitgliedschaft / Features | `technical/MEMBERSHIP_SYSTEM.md`, `architecture/FEATURE_ENFORCEMENT.md` | `backend/auth.py`, Feature-Logging, Router mit Enforcement |
diff --git a/.claude/docs/functional/BLS_FOOD_REFERENCE.md b/.claude/docs/functional/BLS_FOOD_REFERENCE.md
new file mode 100644
index 0000000..9ccbdd3
--- /dev/null
+++ b/.claude/docs/functional/BLS_FOOD_REFERENCE.md
@@ -0,0 +1,21 @@
+# BLS-Lebensmittelreferenz und FDDB-Mapping
+
+**Stand:** 2026-09-12 · **Status:** Phase 1 (Umsetzung)
+
+## WAS
+
+Optionale Grundlage für verlässliche Nährwerte: offizieller Bundeslebensmittelschlüssel (BLS) 4.0 plus manuelle Katalogerweiterung, lernendes Mapping von FDDB-Bezeichnern, persistierte Tagebuchzeilen. Reine Tagesmakros bleiben First Class.
+
+## Fachliche Regeln
+
+- BLS-Code (`bls_code`, Stoff-`attr_key`) bleibt die stabile Identität bei Reimports.
+- BLS 4.0 (~7140 Lebensmittel) ist frei nutzbar (MRI / blsdb.de); Dateien nicht im Git.
+- Mapping ohne KI: Normalisierung + exakter Lookup (User vor Global) + Bestätigung neuer Namen.
+- Gelernte Zuordnungen bleiben dauerhaft, sind aber änder- und löschbar.
+- Ungemappte / Fertiggerichte: FDDB-Makros, keine erfundenen Mikros.
+- Fasten und „unvollständig“ sind explizite Marken, kein Auto-Schluss aus fehlendem Import.
+- Import-Policy (Profil): nachfragen / Katalog überschreiben / FDDB überschreiben / Makros behalten.
+
+## Später
+
+Platzhalter (Registry) für Mikros, Esszeitpunkte (`logged_at`), Fasten; Bezug Gitea #106 (Grundlage) und #75 (Folge).
diff --git a/.claude/docs/technical/BLS_FOOD_REFERENCE.md b/.claude/docs/technical/BLS_FOOD_REFERENCE.md
new file mode 100644
index 0000000..d34e39d
--- /dev/null
+++ b/.claude/docs/technical/BLS_FOOD_REFERENCE.md
@@ -0,0 +1,31 @@
+# BLS Food Reference – technische Spec
+
+**Stand:** 2026-09-12 · Migration **062**
+
+## Tabellen
+
+- `food_attributes` — dynamischer Stoff-/Merkmalskatalog (`attr_key`, `data_type`, `origin`)
+- `food_catalog` — Lebensmittel (`bls_code` UNIQUE bei official_bls; manuell ohne BLS-Code)
+- `food_attribute_values` — typisiertes EAV
+- `food_name_mappings` — FDDB-Name → `food_id` (User / global)
+- `nutrition_items` — Tagebuchzeilen inkl. `logged_at`
+- `nutrition_daily_nutrients` — Tages-Rollup numerischer Attribute
+- `nutrition_day_marks` — `fasting` | `incomplete`
+
+## Layer 1
+
+- `data_layer/food_mapping.py` — Normalisierung, Lookup, Learn, Apply, Delete
+- `data_layer/nutrition_items.py` — Ingest, drei Makro-Summen, Policy, `resolve_*_attributes`
+
+## Import
+
+BLS: Admin-Upload Components + Daten-XLSX (`backend/bls/parser.py`). Upsert über `bls_code` / `attr_key`, nie Delete+Insert offizieller Zeilen.
+
+FDDB: Items persistieren; `nutrition_log` nur bei leerem Tag oder laut Policy / Bestätigung.
+
+## Router
+
+- `/api/bls/*` — Suche, eigene Foods, eigene Mappings
+- `/api/admin/bls/*` — Import, Katalog, Attribute
+- `/api/admin/food-mappings` — Admin-CRUD
+- `/api/nutrition/*` — Items, Unmapped, Bulk-Map, Marken, Konflikt-Resolve
diff --git a/CLAUDE.md b/CLAUDE.md
index 9e0c534..38ee625 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -117,6 +117,14 @@ frontend/src/
- **`main.py`:** `import placeholder_registrations` beim Start, damit die Registry (**114 Keys**, deckungsgleich `PLACEHOLDER_MAP`) und `get_placeholder_catalog()` ohne vorherigen Export-Request konsistent sind.
- **`placeholder_resolver.py`:** `{{top_goal_progress_pct}}` nutzt `_safe_int` statt `_safe_str` (Verdrahtung zu `scores.get_top_priority_goal` korrigiert).
+### Updates (12.09.2026 - BLS-Stammdaten, FDDB-Mapping, Item-Tagebuch)
+
+- **Migration 062:** `food_catalog` (BLS-Code bleibt Identität), dynamische `food_attributes` + EAV, `food_name_mappings`, `nutrition_items`, `nutrition_daily_nutrients`, `nutrition_day_marks`, Import-Policy am Profil.
+- **Admin:** Gruppe Ernährung — BLS-Import, Katalog, Attribute, Mappings.
+- **Nutzer:** Einzelerfassung unverändert; Tab Zuordnen; Fasten/Lücke; Import-Abgleich.
+- **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.
+
### Updates (11.04.2026 - Gitea #75, nutrition_score Registry)
- **Gitea #75** (offen): Zucker/Ballaststoffe/Lebensmittelqualität, automatisches Lebensmittelprofil, später Mahlzeiten-Timing/Abgleich mit Training — http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/75
diff --git a/backend/bls/__init__.py b/backend/bls/__init__.py
new file mode 100644
index 0000000..e183941
--- /dev/null
+++ b/backend/bls/__init__.py
@@ -0,0 +1 @@
+"""BLS 4.0 ingest (official MRI XLSX)."""
diff --git a/backend/bls/import_service.py b/backend/bls/import_service.py
new file mode 100644
index 0000000..8fa872c
--- /dev/null
+++ b/backend/bls/import_service.py
@@ -0,0 +1,126 @@
+"""Apply parsed BLS 4.0 data: upsert attributes and foods, never delete official rows."""
+from __future__ import annotations
+
+from typing import Any
+
+
+def upsert_attributes(cur, attributes: list[dict[str, Any]]) -> dict[str, int]:
+ inserted = updated = 0
+ for a in attributes:
+ cur.execute(
+ """
+ INSERT INTO food_attributes
+ (attr_key, name_de, name_en, unit, category, data_type, origin, sort_order, updated_at)
+ VALUES (%s, %s, %s, %s, %s, %s, 'official_bls', %s, NOW())
+ ON CONFLICT (attr_key) DO UPDATE SET
+ name_de = EXCLUDED.name_de,
+ name_en = COALESCE(EXCLUDED.name_en, food_attributes.name_en),
+ unit = COALESCE(EXCLUDED.unit, food_attributes.unit),
+ category = COALESCE(EXCLUDED.category, food_attributes.category),
+ sort_order = EXCLUDED.sort_order,
+ updated_at = NOW()
+ WHERE food_attributes.origin = 'official_bls'
+ RETURNING (xmax = 0) AS inserted
+ """,
+ (
+ a["attr_key"], a["name_de"], a.get("name_en"), a.get("unit"),
+ a.get("category"), a.get("data_type") or "num_per_100g",
+ a.get("sort_order") or 0,
+ ),
+ )
+ row = cur.fetchone()
+ if row and row.get("inserted"):
+ inserted += 1
+ else:
+ updated += 1
+ return {"inserted": inserted, "updated": updated, "total": len(attributes)}
+
+
+def upsert_foods(cur, foods: list[dict[str, Any]], bls_version: str = "4.0") -> dict[str, int]:
+ cur.execute("SELECT attr_key, id FROM food_attributes")
+ attr_ids = {r["attr_key"]: r["id"] for r in cur.fetchall()}
+ inserted = updated = values_written = 0
+ for f in foods:
+ code = f["bls_code"]
+ cur.execute("SELECT id FROM food_catalog WHERE bls_code = %s", (code,))
+ existing = cur.fetchone()
+ if existing:
+ food_id = existing["id"]
+ cur.execute(
+ """
+ UPDATE food_catalog
+ SET name_de=%s, name_en=%s, food_group=%s, bls_version=%s,
+ catalog_kind='official_bls', source='bls_4.0', is_active=true, updated_at=NOW()
+ WHERE id=%s AND catalog_kind='official_bls'
+ """,
+ (f["name_de"], f.get("name_en"), f.get("food_group"), bls_version, food_id),
+ )
+ updated += 1
+ else:
+ cur.execute(
+ """
+ INSERT INTO food_catalog
+ (bls_code, name_de, name_en, food_group, catalog_kind, bls_version, source)
+ VALUES (%s, %s, %s, %s, 'official_bls', %s, 'bls_4.0')
+ RETURNING id
+ """,
+ (code, f["name_de"], f.get("name_en"), f.get("food_group"), bls_version),
+ )
+ food_id = cur.fetchone()["id"]
+ inserted += 1
+ for val in f.get("values") or []:
+ aid = attr_ids.get(val["attr_key"])
+ if not aid:
+ continue
+ if val.get("is_trace"):
+ cur.execute(
+ """
+ INSERT INTO food_attribute_values
+ (food_id, attribute_id, value_num, is_trace, origin_code, reference_text, updated_at)
+ VALUES (%s, %s, NULL, true, %s, %s, NOW())
+ ON CONFLICT (food_id, attribute_id) DO UPDATE SET
+ value_num = NULL, is_trace = true,
+ origin_code = EXCLUDED.origin_code,
+ reference_text = EXCLUDED.reference_text,
+ updated_at = NOW()
+ """,
+ (food_id, aid, val.get("origin_code"), val.get("reference_text")),
+ )
+ elif val.get("value_num") is None:
+ cur.execute(
+ """
+ INSERT INTO food_attribute_values
+ (food_id, attribute_id, value_num, is_trace, origin_code, reference_text, updated_at)
+ VALUES (%s, %s, NULL, false, %s, %s, NOW())
+ ON CONFLICT (food_id, attribute_id) DO UPDATE SET
+ value_num = NULL, is_trace = false,
+ origin_code = EXCLUDED.origin_code,
+ reference_text = EXCLUDED.reference_text,
+ updated_at = NOW()
+ """,
+ (food_id, aid, val.get("origin_code"), val.get("reference_text")),
+ )
+ else:
+ cur.execute(
+ """
+ INSERT INTO food_attribute_values
+ (food_id, attribute_id, value_num, is_trace, origin_code, reference_text, updated_at)
+ VALUES (%s, %s, %s, false, %s, %s, NOW())
+ ON CONFLICT (food_id, attribute_id) DO UPDATE SET
+ value_num = EXCLUDED.value_num, is_trace = false,
+ origin_code = EXCLUDED.origin_code,
+ reference_text = EXCLUDED.reference_text,
+ updated_at = NOW()
+ """,
+ (
+ food_id, aid, val["value_num"],
+ val.get("origin_code"), val.get("reference_text"),
+ ),
+ )
+ values_written += 1
+ return {
+ "foods_inserted": inserted,
+ "foods_updated": updated,
+ "values_written": values_written,
+ "foods_total": len(foods),
+ }
diff --git a/backend/bls/parser.py b/backend/bls/parser.py
new file mode 100644
index 0000000..63e90ee
--- /dev/null
+++ b/backend/bls/parser.py
@@ -0,0 +1,158 @@
+"""Parse official BLS 4.0 XLSX files without a hardcoded nutrient code list."""
+from __future__ import annotations
+
+import re
+from io import BytesIO
+from typing import Any
+
+from openpyxl import load_workbook
+
+# Header like: "ENERCJ Energie (Kilojoule) [kJ/100g]"
+ATTR_HEADER_RE = re.compile(
+ r"^([A-Z][A-Z0-9:]{1,20})\s+(.+?)(?:\s*\[([^\]]+)\])?\s*$"
+)
+
+
+def _cell(v: Any) -> str:
+ if v is None:
+ return ""
+ return str(v).strip()
+
+
+def parse_components_xlsx(data: bytes) -> list[dict[str, Any]]:
+ wb = load_workbook(filename=BytesIO(data), read_only=True, data_only=True)
+ ws = wb.active
+ rows = ws.iter_rows(values_only=True)
+ header = [_cell(c) for c in next(rows)]
+ idx = {h.lower(): i for i, h in enumerate(header) if h}
+
+ def col(*names: str) -> int | None:
+ for n in names:
+ if n.lower() in idx:
+ return idx[n.lower()]
+ for key, i in idx.items():
+ for n in names:
+ if n.lower() in key:
+ return i
+ return None
+
+ i_code = col("code", "schlüssel", "schluessel", "attr_key", "komponente")
+ i_de = col("name_de", "deutsch", "bezeichnung_de", "name de")
+ i_en = col("name_en", "english", "bezeichnung_en", "name en")
+ i_unit = col("unit", "einheit")
+ i_cat = col("category", "kategorie", "gruppe")
+ if i_code is None:
+ i_code = 0
+ if i_de is None:
+ i_de = 1 if len(header) > 1 else 0
+
+ out = []
+ sort_order = 0
+ for raw in rows:
+ if not raw:
+ continue
+ code = _cell(raw[i_code] if i_code < len(raw) else "")
+ if not code or code.lower() in ("code", "schlüssel", "schluessel"):
+ continue
+ name_de = _cell(raw[i_de] if i_de is not None and i_de < len(raw) else "") or code
+ name_en = _cell(raw[i_en] if i_en is not None and i_en < len(raw) else "") or None
+ unit = _cell(raw[i_unit] if i_unit is not None and i_unit < len(raw) else "") or None
+ category = _cell(raw[i_cat] if i_cat is not None and i_cat < len(raw) else "") or None
+ sort_order += 1
+ out.append({
+ "attr_key": code,
+ "name_de": name_de,
+ "name_en": name_en,
+ "unit": unit,
+ "category": category,
+ "data_type": "num_per_100g",
+ "origin": "official_bls",
+ "sort_order": sort_order,
+ })
+ wb.close()
+ return out
+
+
+def _parse_value_header(title: str) -> tuple[str | None, str, str | None]:
+ t = title.strip()
+ m = ATTR_HEADER_RE.match(t)
+ if m:
+ return m.group(1), m.group(2).strip(), m.group(3)
+ # Fallback: first token
+ parts = t.split()
+ if parts and re.match(r"^[A-Z][A-Z0-9:]{1,20}$", parts[0]):
+ return parts[0], " ".join(parts[1:]) or parts[0], None
+ return None, t, None
+
+
+def parse_foods_xlsx(data: bytes) -> dict[str, Any]:
+ wb = load_workbook(filename=BytesIO(data), read_only=True, data_only=True)
+ ws = wb.active
+ rows = ws.iter_rows(values_only=True)
+ header = [_cell(c) for c in next(rows)]
+ if len(header) < 3:
+ wb.close()
+ raise ValueError("BLS-Datendatei: erwartet mindestens BLS-Code, Name DE, Name EN")
+
+ triples: list[dict[str, Any]] = []
+ i = 3
+ while i < len(header):
+ code, name_de, unit = _parse_value_header(header[i])
+ origin_i = i + 1 if i + 1 < len(header) else None
+ ref_i = i + 2 if i + 2 < len(header) else None
+ triples.append({
+ "attr_key": code or f"COL{i}",
+ "name_de": name_de,
+ "unit": unit,
+ "value_col": i,
+ "origin_col": origin_i,
+ "ref_col": ref_i,
+ })
+ i += 3 if (origin_i is not None and ref_i is not None) else 1
+
+ foods = []
+ for raw in rows:
+ if not raw:
+ continue
+ code = _cell(raw[0] if len(raw) else "")
+ if not code:
+ continue
+ name_de = _cell(raw[1] if len(raw) > 1 else "") or code
+ name_en = _cell(raw[2] if len(raw) > 2 else "") or None
+ values = []
+ for t in triples:
+ vc = t["value_col"]
+ raw_v = raw[vc] if vc < len(raw) else None
+ is_trace = False
+ num = None
+ if raw_v is None or raw_v == "" or raw_v == "-":
+ num = None
+ elif str(raw_v).strip().upper() in ("TR", "TRACE", "SPUREN"):
+ is_trace = True
+ else:
+ try:
+ num = float(str(raw_v).replace(",", "."))
+ except (TypeError, ValueError):
+ num = None
+ origin = ""
+ if t["origin_col"] is not None and t["origin_col"] < len(raw):
+ origin = _cell(raw[t["origin_col"]])
+ ref = ""
+ if t["ref_col"] is not None and t["ref_col"] < len(raw):
+ ref = _cell(raw[t["ref_col"]])
+ values.append({
+ "attr_key": t["attr_key"],
+ "value_num": num,
+ "is_trace": is_trace,
+ "origin_code": origin or None,
+ "reference_text": ref or None,
+ })
+ foods.append({
+ "bls_code": code,
+ "name_de": name_de,
+ "name_en": name_en,
+ "food_group": code[0] if code else None,
+ "values": values,
+ })
+ wb.close()
+ return {"attribute_headers": triples, "foods": foods}
diff --git a/backend/csv_parser/executor.py b/backend/csv_parser/executor.py
index 6a63874..a1106c5 100644
--- a/backend/csv_parser/executor.py
+++ b/backend/csv_parser/executor.py
@@ -223,6 +223,40 @@ def _import_nutrition(
skipped_groups = sum(n.get("rows_in_group", 0) for n in (agg_notes or []) if n.get("error") == "mehrere_zeilen_pro_schluessel")
+ item_rows = []
+ for mapped in mapped_rows:
+ name = mapped.get("food_name")
+ if not name:
+ continue
+ d_item = coerce_date(mapped.get("date"))
+ if d_item is None:
+ continue
+ item_rows.append({
+ "date": d_item.isoformat(),
+ "logged_at": mapped.get("logged_at") or mapped.get("date"),
+ "food_name": str(name).strip(),
+ "quantity_raw": mapped.get("quantity_raw"),
+ "kcal": mapped.get("kcal"),
+ "protein_g": mapped.get("protein_g"),
+ "fat_g": mapped.get("fat_g"),
+ "carbs_g": mapped.get("carbs_g"),
+ })
+ if item_rows:
+ from data_layer.nutrition_items import get_import_policy, replace_csv_items_for_dates
+
+ policy = get_import_policy(cur, profile_id)
+ ingest = replace_csv_items_for_dates(cur, profile_id, item_rows, policy=policy)
+ return {
+ "rows_total": rows_total,
+ "inserted": ingest.get("new_log_days", 0),
+ "updated": max(0, ingest.get("days_written", 0) - ingest.get("new_log_days", 0)),
+ "skipped": skipped_groups,
+ "new_entries": ingest.get("new_log_days", 0),
+ "items_written": ingest.get("items_written", 0),
+ "conflicts": ingest.get("conflicts") or [],
+ "policy": ingest.get("policy"),
+ }
+
inserted = 0
updated = 0
new_entries = 0
diff --git a/backend/csv_parser/module_registry.py b/backend/csv_parser/module_registry.py
index 3786327..1d16b96 100644
--- a/backend/csv_parser/module_registry.py
+++ b/backend/csv_parser/module_registry.py
@@ -20,6 +20,8 @@ MODULE_DEFINITIONS: Dict[str, Dict[str, Any]] = {
"protein_g": {"type": "float", "required": False, "min": 0, "unit": "g"},
"fat_g": {"type": "float", "required": False, "min": 0, "unit": "g"},
"carbs_g": {"type": "float", "required": False, "min": 0, "unit": "g"},
+ "food_name": {"type": "string", "required": False, "label_de": "Lebensmittel"},
+ "quantity_raw": {"type": "string", "required": False, "label_de": "Menge"},
},
"duplicate_key": ["profile_id", "date"],
"duplicate_strategy": "update",
diff --git a/backend/data_layer/food_mapping.py b/backend/data_layer/food_mapping.py
new file mode 100644
index 0000000..128402d
--- /dev/null
+++ b/backend/data_layer/food_mapping.py
@@ -0,0 +1,194 @@
+"""FDDB → food_catalog mapping: normalize, lookup (user then global), learn, apply."""
+from __future__ import annotations
+
+import re
+import unicodedata
+from typing import Any
+
+LEADING_QTY_RE = re.compile(
+ r"^\s*\d+(?:[.,]\d+)?\s*(?:g|kg|ml|l|stück|stk|st\.?|portion(?:en)?)\b[\s,.:\-–]*",
+ re.IGNORECASE,
+)
+MULTISPACE_RE = re.compile(r"\s+")
+DECIMAL_IN_NAME_RE = re.compile(r"(\d),(\d)")
+
+
+def normalize_food_name(raw: str | None) -> str:
+ if not raw:
+ return ""
+ s = unicodedata.normalize("NFKC", str(raw)).strip().strip('"').strip("'")
+ s = LEADING_QTY_RE.sub("", s)
+ s = DECIMAL_IN_NAME_RE.sub(r"\1.\2", s)
+ s = MULTISPACE_RE.sub(" ", s).strip().lower()
+ return s
+
+
+def parse_quantity_g(raw: str | None) -> float | None:
+ if raw is None or str(raw).strip() == "":
+ return None
+ 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)
+ return None
+
+
+def get_food_mapping_with_cursor(
+ cur,
+ source_name: str,
+ profile_id: str | None = None,
+ source_system: str = "fddb",
+) -> dict[str, Any] | None:
+ norm = normalize_food_name(source_name)
+ if not norm:
+ return None
+ if profile_id:
+ cur.execute(
+ """
+ SELECT m.id AS mapping_id, m.food_id, m.profile_id, m.source,
+ f.bls_code, f.name_de, f.catalog_kind
+ FROM food_name_mappings m
+ JOIN food_catalog f ON f.id = m.food_id
+ WHERE m.source_system = %s AND m.source_name_normalized = %s
+ AND m.profile_id = %s
+ LIMIT 1
+ """,
+ (source_system, norm, profile_id),
+ )
+ row = cur.fetchone()
+ if row:
+ return dict(row)
+ cur.execute(
+ """
+ SELECT m.id AS mapping_id, m.food_id, m.profile_id, m.source,
+ f.bls_code, f.name_de, f.catalog_kind
+ FROM food_name_mappings m
+ JOIN food_catalog f ON f.id = m.food_id
+ WHERE m.source_system = %s AND m.source_name_normalized = %s
+ AND m.profile_id IS NULL
+ LIMIT 1
+ """,
+ (source_system, norm),
+ )
+ row = cur.fetchone()
+ return dict(row) if row else None
+
+
+def upsert_food_mapping(
+ cur,
+ *,
+ source_name_raw: str,
+ food_id: str,
+ profile_id: str | None,
+ source: str = "bulk",
+ source_system: str = "fddb",
+) -> int:
+ norm = normalize_food_name(source_name_raw)
+ if not norm:
+ raise ValueError("Leerer Lebensmittelname")
+ if profile_id:
+ cur.execute(
+ """
+ SELECT id FROM food_name_mappings
+ WHERE source_system = %s AND source_name_normalized = %s AND profile_id = %s
+ """,
+ (source_system, norm, profile_id),
+ )
+ else:
+ cur.execute(
+ """
+ SELECT id FROM food_name_mappings
+ WHERE source_system = %s AND source_name_normalized = %s AND profile_id IS NULL
+ """,
+ (source_system, norm),
+ )
+ existing = cur.fetchone()
+ raw = source_name_raw.strip()
+ if existing:
+ cur.execute(
+ """
+ UPDATE food_name_mappings
+ SET food_id = %s, source_name_raw = %s, source = %s, updated_at = NOW()
+ WHERE id = %s
+ """,
+ (food_id, raw, source, 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())
+ RETURNING id
+ """,
+ (source_system, raw, norm, food_id, profile_id, source),
+ )
+ return int(cur.fetchone()["id"])
+
+
+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(
+ """
+ UPDATE nutrition_items
+ SET food_id = %s, mapping_id = %s, value_origin = %s, updated_at = NOW()
+ WHERE profile_id = %s AND source_name_normalized = %s
+ """,
+ (food_id, mapping_id, origin, profile_id, source_name_normalized),
+ )
+ return cur.rowcount or 0
+
+
+def clear_mapping_from_items(cur, profile_id: str, source_name_normalized: str) -> int:
+ cur.execute(
+ """
+ UPDATE nutrition_items
+ SET food_id = NULL, mapping_id = NULL, value_origin = 'fddb', updated_at = NOW()
+ WHERE profile_id = %s AND source_name_normalized = %s
+ """,
+ (profile_id, source_name_normalized),
+ )
+ return cur.rowcount or 0
+
+
+def _value_origin_for_food(cur, food_id: str) -> str:
+ cur.execute("SELECT catalog_kind FROM food_catalog WHERE id = %s", (food_id,))
+ row = cur.fetchone()
+ if not row:
+ return "fddb"
+ kind = row["catalog_kind"]
+ if kind == "official_bls":
+ return "bls"
+ return "manual_catalog"
+
+
+def suggest_catalog_foods(cur, query: str, profile_id: str | None, limit: int = 8) -> list[dict]:
+ q = (query or "").strip()
+ if not q:
+ return []
+ like = f"%{q}%"
+ norm = normalize_food_name(q)
+ cur.execute(
+ """
+ SELECT id, bls_code, name_de, name_en, catalog_kind, food_group
+ FROM food_catalog
+ WHERE is_active = true
+ AND (
+ owner_profile_id IS NULL
+ OR owner_profile_id = %s
+ )
+ AND (
+ name_de ILIKE %s OR COALESCE(name_en, '') ILIKE %s
+ OR COALESCE(bls_code, '') ILIKE %s
+ OR lower(name_de) = %s
+ )
+ ORDER BY
+ CASE WHEN lower(name_de) = %s THEN 0
+ WHEN COALESCE(bls_code, '') ILIKE %s THEN 1
+ ELSE 2 END,
+ name_de
+ LIMIT %s
+ """,
+ (profile_id, like, like, like, norm, norm, q, limit),
+ )
+ return [dict(r) for r in cur.fetchall()]
diff --git a/backend/data_layer/nutrition_items.py b/backend/data_layer/nutrition_items.py
new file mode 100644
index 0000000..949f1fd
--- /dev/null
+++ b/backend/data_layer/nutrition_items.py
@@ -0,0 +1,437 @@
+"""Nutrition diary items, three macro sums, import policy, attribute resolve."""
+from __future__ import annotations
+
+import uuid
+from datetime import date, datetime
+from typing import Any
+
+from data_layer.food_mapping import (
+ get_food_mapping_with_cursor,
+ normalize_food_name,
+ parse_quantity_g,
+)
+
+MACRO_ATTR_KEYS = {
+ "kcal": "ENERCC",
+ "protein_g": "PROT625",
+ "fat_g": "FAT",
+ "carbs_g": "CHO",
+}
+
+POLICIES = frozenset({"prompt", "overwrite_catalog", "overwrite_fddb", "keep_existing"})
+
+
+def _f(v: Any) -> float:
+ if v is None or v == "":
+ return 0.0
+ try:
+ return float(v)
+ except (TypeError, ValueError):
+ return 0.0
+
+
+def _round_macros(d: dict[str, float]) -> dict[str, float]:
+ return {
+ "kcal": round(_f(d.get("kcal")), 1),
+ "protein_g": round(_f(d.get("protein_g")), 1),
+ "fat_g": round(_f(d.get("fat_g")), 1),
+ "carbs_g": round(_f(d.get("carbs_g")), 1),
+ }
+
+
+def macros_differ(a: dict[str, float], b: dict[str, float]) -> bool:
+ aa, bb = _round_macros(a), _round_macros(b)
+ return any(aa[k] != bb[k] for k in aa)
+
+
+def get_import_policy(cur, profile_id: str) -> str:
+ cur.execute(
+ "SELECT nutrition_import_conflict_policy FROM profiles WHERE id = %s",
+ (profile_id,),
+ )
+ row = cur.fetchone()
+ if not row:
+ return "prompt"
+ pol = row.get("nutrition_import_conflict_policy") or "prompt"
+ return pol if pol in POLICIES else "prompt"
+
+
+def catalog_macros_for_item(cur, food_id: str | None, quantity_g: float | None) -> dict[str, float] | None:
+ if not food_id or quantity_g is None or quantity_g <= 0:
+ return None
+ cur.execute(
+ """
+ SELECT a.attr_key, v.value_num, v.is_trace
+ FROM food_attribute_values v
+ JOIN food_attributes a ON a.id = v.attribute_id
+ WHERE v.food_id = %s AND a.attr_key = ANY(%s) AND a.data_type = 'num_per_100g'
+ """,
+ (food_id, list(MACRO_ATTR_KEYS.values())),
+ )
+ by_key = {r["attr_key"]: r for r in cur.fetchall()}
+ if not by_key:
+ return None
+ out = {}
+ factor = float(quantity_g) / 100.0
+ missing = False
+ for field, key in MACRO_ATTR_KEYS.items():
+ row = by_key.get(key)
+ if not row or row.get("is_trace") or row.get("value_num") is None:
+ missing = True
+ break
+ out[field] = float(row["value_num"]) * factor
+ return None if missing else out
+
+
+def compute_day_macro_sums(cur, profile_id: str, day: date | str) -> dict[str, Any]:
+ cur.execute(
+ """
+ SELECT kcal, protein_g, fat_g, carbs_g, macro_origin, has_items
+ FROM nutrition_log WHERE profile_id = %s AND date = %s
+ """,
+ (profile_id, day),
+ )
+ existing = cur.fetchone()
+ existing_macros = (
+ _round_macros(existing)
+ if existing
+ else None
+ )
+ cur.execute(
+ """
+ SELECT food_id, quantity_g, fddb_kcal, fddb_protein_g, fddb_fat_g, fddb_carbs_g, value_origin
+ FROM nutrition_items
+ WHERE profile_id = %s AND date = %s
+ """,
+ (profile_id, day),
+ )
+ items = cur.fetchall()
+ fddb = {"kcal": 0.0, "protein_g": 0.0, "fat_g": 0.0, "carbs_g": 0.0}
+ catalog = {"kcal": 0.0, "protein_g": 0.0, "fat_g": 0.0, "carbs_g": 0.0}
+ mapped = unmapped = 0
+ used_bls = used_fddb = False
+ for it in items:
+ fddb["kcal"] += _f(it.get("fddb_kcal"))
+ fddb["protein_g"] += _f(it.get("fddb_protein_g"))
+ fddb["fat_g"] += _f(it.get("fddb_fat_g"))
+ fddb["carbs_g"] += _f(it.get("fddb_carbs_g"))
+ cat = catalog_macros_for_item(cur, it.get("food_id"), it.get("quantity_g"))
+ if cat:
+ mapped += 1
+ used_bls = True
+ for k in catalog:
+ catalog[k] += cat[k]
+ else:
+ unmapped += 1
+ used_fddb = True
+ catalog["kcal"] += _f(it.get("fddb_kcal"))
+ catalog["protein_g"] += _f(it.get("fddb_protein_g"))
+ catalog["fat_g"] += _f(it.get("fddb_fat_g"))
+ catalog["carbs_g"] += _f(it.get("fddb_carbs_g"))
+ origin = "mixed"
+ if used_bls and not used_fddb:
+ origin = "bls"
+ elif used_fddb and not used_bls:
+ origin = "fddb"
+ if not items:
+ origin = "manual"
+ return {
+ "existing": existing_macros,
+ "fddb": _round_macros(fddb) if items else None,
+ "catalog": _round_macros(catalog) if items else None,
+ "mapped_item_count": mapped,
+ "unmapped_item_count": unmapped,
+ "has_items": bool(items),
+ "catalog_origin": origin,
+ "has_log": existing is not None,
+ "macro_origin": existing["macro_origin"] if existing else None,
+ }
+
+
+def apply_nutrition_day_macros(
+ cur,
+ profile_id: str,
+ day: date | str,
+ macros: dict[str, float],
+ *,
+ macro_origin: str,
+ source: str = "csv",
+ confirm: bool = False,
+) -> str:
+ m = _round_macros(macros)
+ cur.execute("SELECT id FROM nutrition_log WHERE profile_id = %s AND date = %s", (profile_id, day))
+ row = cur.fetchone()
+ counts = compute_day_macro_sums(cur, profile_id, day)
+ extra = (
+ counts["mapped_item_count"],
+ counts["unmapped_item_count"],
+ counts["has_items"],
+ )
+ confirmed = datetime.utcnow() if confirm else None
+ if row:
+ cur.execute(
+ """
+ UPDATE nutrition_log
+ SET kcal=%s, protein_g=%s, fat_g=%s, carbs_g=%s, source=%s,
+ macro_origin=%s, mapped_item_count=%s, unmapped_item_count=%s,
+ has_items=%s, last_import_at=NOW(), macros_confirmed_at=COALESCE(%s, macros_confirmed_at)
+ WHERE profile_id=%s AND date=%s
+ """,
+ (
+ m["kcal"], m["protein_g"], m["fat_g"], m["carbs_g"], source,
+ macro_origin, extra[0], extra[1], extra[2], confirmed, profile_id, day,
+ ),
+ )
+ return "updated"
+ eid = str(uuid.uuid4())
+ cur.execute(
+ """
+ INSERT INTO nutrition_log (
+ id, profile_id, date, kcal, protein_g, fat_g, carbs_g, source,
+ macro_origin, mapped_item_count, unmapped_item_count, has_items,
+ last_import_at, macros_confirmed_at, created
+ ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,NOW(),%s,CURRENT_TIMESTAMP)
+ """,
+ (
+ eid, profile_id, day, m["kcal"], m["protein_g"], m["fat_g"], m["carbs_g"], source,
+ macro_origin, extra[0], extra[1], extra[2], confirmed,
+ ),
+ )
+ return "created"
+
+
+def rebuild_daily_nutrients(cur, profile_id: str, day: date | str) -> None:
+ cur.execute(
+ "DELETE FROM nutrition_daily_nutrients WHERE profile_id = %s AND date = %s",
+ (profile_id, day),
+ )
+ cur.execute(
+ """
+ SELECT i.food_id, i.quantity_g
+ FROM nutrition_items i
+ WHERE i.profile_id = %s AND i.date = %s
+ AND i.food_id IS NOT NULL AND i.quantity_g IS NOT NULL AND i.quantity_g > 0
+ """,
+ (profile_id, day),
+ )
+ acc: dict[int, list[float]] = {}
+ for it in cur.fetchall():
+ cur.execute(
+ """
+ SELECT v.attribute_id, v.value_num, v.is_trace, a.data_type
+ FROM food_attribute_values v
+ JOIN food_attributes a ON a.id = v.attribute_id
+ WHERE v.food_id = %s AND a.data_type = 'num_per_100g'
+ AND v.value_num IS NOT NULL AND v.is_trace = false
+ """,
+ (it["food_id"],),
+ )
+ factor = float(it["quantity_g"]) / 100.0
+ for row in cur.fetchall():
+ acc.setdefault(row["attribute_id"], []).append(float(row["value_num"]) * factor)
+ for attr_id, vals in acc.items():
+ cur.execute(
+ """
+ INSERT INTO nutrition_daily_nutrients
+ (profile_id, date, attribute_id, value, contributing_item_count, updated_at)
+ VALUES (%s, %s, %s, %s, %s, NOW())
+ """,
+ (profile_id, day, attr_id, round(sum(vals), 6), len(vals)),
+ )
+
+
+def _item_value_origin(mapping: dict | None) -> str:
+ if not mapping:
+ return "fddb"
+ kind = mapping.get("catalog_kind")
+ if kind == "official_bls":
+ return "bls"
+ if kind in ("manual_admin", "manual_user"):
+ return "manual_catalog"
+ return "fddb"
+
+
+def replace_csv_items_for_dates(
+ cur,
+ profile_id: str,
+ rows: list[dict[str, Any]],
+ *,
+ policy: str,
+ policy_override: str | None = None,
+) -> dict[str, Any]:
+ """
+ Replace csv-sourced items for the dates present in rows.
+ Returns conflicts when policy is prompt and existing macros differ.
+ """
+ effective = policy_override if policy_override in POLICIES else policy
+ by_date: dict[str, list[dict]] = {}
+ for row in rows:
+ d = row.get("date")
+ if hasattr(d, "isoformat"):
+ iso = d.isoformat()
+ else:
+ iso = str(d)[:10]
+ if not iso:
+ continue
+ by_date.setdefault(iso, []).append(row)
+
+ conflicts = []
+ days_written = 0
+ items_written = 0
+ new_log_days = 0
+
+ for iso, day_rows in by_date.items():
+ cur.execute(
+ """
+ DELETE FROM nutrition_items
+ WHERE profile_id = %s AND date = %s AND source = 'csv'
+ """,
+ (profile_id, iso),
+ )
+ for raw in day_rows:
+ name = (raw.get("food_name") or raw.get("source_name_raw") or "").strip()
+ 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)
+ mapping = get_food_mapping_with_cursor(cur, name, profile_id)
+ logged_at = raw.get("logged_at")
+ cur.execute(
+ """
+ INSERT INTO nutrition_items (
+ id, profile_id, date, logged_at, source_name_raw, source_name_normalized,
+ source_system, quantity_raw, quantity_g,
+ fddb_kcal, fddb_protein_g, fddb_fat_g, fddb_carbs_g,
+ food_id, mapping_id, value_origin, source
+ ) VALUES (
+ %s,%s,%s,%s,%s,%s,'fddb',%s,%s,%s,%s,%s,%s,%s,%s,%s,'csv'
+ )
+ """,
+ (
+ str(uuid.uuid4()),
+ profile_id,
+ iso,
+ logged_at,
+ name,
+ normalize_food_name(name),
+ str(qty_raw) if qty_raw is not None else None,
+ qty_g,
+ _f(raw.get("fddb_kcal") if raw.get("fddb_kcal") is not None else raw.get("kcal")),
+ _f(raw.get("fddb_protein_g") if raw.get("fddb_protein_g") is not None else raw.get("protein_g")),
+ _f(raw.get("fddb_fat_g") if raw.get("fddb_fat_g") is not None else raw.get("fat_g")),
+ _f(raw.get("fddb_carbs_g") if raw.get("fddb_carbs_g") is not None else raw.get("carbs_g")),
+ mapping["food_id"] if mapping else None,
+ mapping["mapping_id"] if mapping else None,
+ _item_value_origin(mapping),
+ ),
+ )
+ items_written += 1
+ days_written += 1
+ sums = compute_day_macro_sums(cur, profile_id, iso)
+ rebuild_daily_nutrients(cur, profile_id, iso)
+ if not sums["has_items"]:
+ continue
+ if not sums["has_log"]:
+ apply_nutrition_day_macros(
+ cur, profile_id, iso, sums["catalog"],
+ macro_origin=sums["catalog_origin"], source="csv",
+ )
+ new_log_days += 1
+ continue
+ existing = sums["existing"]
+ catalog = sums["catalog"]
+ fddb = sums["fddb"]
+ differ = macros_differ(existing, catalog) or macros_differ(existing, fddb)
+ if effective == "keep_existing":
+ _touch_item_counts(cur, profile_id, iso, sums)
+ continue
+ if effective == "overwrite_catalog":
+ apply_nutrition_day_macros(
+ cur, profile_id, iso, catalog,
+ macro_origin=sums["catalog_origin"], source="csv",
+ )
+ continue
+ if effective == "overwrite_fddb":
+ apply_nutrition_day_macros(
+ cur, profile_id, iso, fddb, macro_origin="fddb", source="csv",
+ )
+ continue
+ # prompt
+ if differ:
+ conflicts.append({
+ "date": iso,
+ "existing": existing,
+ "fddb": fddb,
+ "catalog": catalog,
+ "catalog_origin": sums["catalog_origin"],
+ "mapped_item_count": sums["mapped_item_count"],
+ "unmapped_item_count": sums["unmapped_item_count"],
+ })
+ else:
+ apply_nutrition_day_macros(
+ cur, profile_id, iso, catalog,
+ macro_origin=sums["catalog_origin"], source="csv",
+ )
+
+ return {
+ "days_written": days_written,
+ "items_written": items_written,
+ "new_log_days": new_log_days,
+ "conflicts": conflicts,
+ "policy": effective,
+ }
+
+
+def _touch_item_counts(cur, profile_id: str, day: str, sums: dict) -> None:
+ cur.execute(
+ """
+ UPDATE nutrition_log
+ SET mapped_item_count=%s, unmapped_item_count=%s, has_items=%s, last_import_at=NOW()
+ WHERE profile_id=%s AND date=%s
+ """,
+ (
+ sums["mapped_item_count"],
+ sums["unmapped_item_count"],
+ sums["has_items"],
+ profile_id,
+ day,
+ ),
+ )
+
+
+def resolve_choice_macros(sums: dict[str, Any], choice: str) -> tuple[dict[str, float], str]:
+ if choice == "existing":
+ return sums["existing"], "user_confirmed"
+ if choice == "fddb":
+ return sums["fddb"], "fddb"
+ if choice == "catalog":
+ return sums["catalog"], sums.get("catalog_origin") or "mixed"
+ raise ValueError("Ungültige Wahl (existing|fddb|catalog)")
+
+
+def resolve_food_attributes(cur, food_id: str) -> list[dict]:
+ cur.execute(
+ """
+ SELECT a.attr_key, a.name_de, a.unit, a.category, a.data_type, a.origin AS attr_origin,
+ v.value_num, v.value_bool, v.value_text, v.is_trace, v.origin_code
+ FROM food_attributes a
+ LEFT JOIN food_attribute_values v
+ ON v.attribute_id = a.id AND v.food_id = %s
+ WHERE a.is_active = true
+ ORDER BY a.sort_order, a.attr_key
+ """,
+ (food_id,),
+ )
+ return [dict(r) for r in cur.fetchall()]
+
+
+def dates_for_normalized_name(cur, profile_id: str, source_name_normalized: str) -> list[str]:
+ cur.execute(
+ """
+ SELECT DISTINCT date::text AS date
+ FROM nutrition_items
+ WHERE profile_id = %s AND source_name_normalized = %s
+ """,
+ (profile_id, source_name_normalized),
+ )
+ return [r["date"] for r in cur.fetchall()]
diff --git a/backend/main.py b/backend/main.py
index 584ff80..af22ea2 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -38,6 +38,7 @@ from routers import app_dashboard # Geschützter App-Bereich: Dashboard-Layout
from routers import reports # Strukturierter PDF-Bericht (Profil v1)
from routers import csv_import, admin_csv_templates # Issue #21 Universal CSV Parser
from routers import admin_training_parameters, admin_activity_attribute_profiles # EAV session metrics
+from routers import bls, admin_bls, admin_food_mappings # BLS catalog + FDDB mapping
# ── App Configuration ─────────────────────────────────────────────────────────
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
@@ -133,6 +134,9 @@ app.include_router(csv_import.router) # /api/csv/* (Issue #21)
app.include_router(admin_csv_templates.router) # /api/admin/csv-templates/* (Issue #21)
app.include_router(admin_training_parameters.router) # /api/admin/training-parameters
app.include_router(admin_activity_attribute_profiles.router) # /api/admin/training-*-parameters
+app.include_router(bls.router) # /api/bls/*
+app.include_router(admin_bls.router) # /api/admin/bls/*
+app.include_router(admin_food_mappings.router) # /api/admin/food-mappings
# ── Health Check ──────────────────────────────────────────────────────────────
@app.get("/")
diff --git a/backend/migrations/062_bls_food_catalog.sql b/backend/migrations/062_bls_food_catalog.sql
new file mode 100644
index 0000000..0541e0a
--- /dev/null
+++ b/backend/migrations/062_bls_food_catalog.sql
@@ -0,0 +1,205 @@
+-- Migration 062: BLS/food catalog, typed attributes, FDDB mapping, nutrition items, day marks
+-- Additive only. Official BLS rows upsert by bls_code / attr_key — never delete+reinsert.
+
+CREATE TABLE IF NOT EXISTS food_attributes (
+ id SERIAL PRIMARY KEY,
+ attr_key VARCHAR(64) NOT NULL,
+ name_de VARCHAR(255) NOT NULL,
+ name_en VARCHAR(255),
+ unit VARCHAR(40),
+ category VARCHAR(80),
+ data_type VARCHAR(20) NOT NULL DEFAULT 'num_per_100g'
+ CHECK (data_type IN ('num_per_100g', 'boolean', 'text', 'enum')),
+ enum_values JSONB,
+ origin VARCHAR(20) NOT NULL DEFAULT 'official_bls'
+ CHECK (origin IN ('official_bls', 'extension')),
+ sort_order INT NOT NULL DEFAULT 0,
+ is_active BOOLEAN NOT NULL DEFAULT true,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ CONSTRAINT uq_food_attributes_key UNIQUE (attr_key)
+);
+
+CREATE INDEX IF NOT EXISTS idx_food_attributes_origin ON food_attributes (origin);
+
+CREATE TABLE IF NOT EXISTS food_catalog (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ bls_code VARCHAR(16),
+ external_key VARCHAR(80),
+ name_de VARCHAR(500) NOT NULL,
+ name_en VARCHAR(500),
+ food_group VARCHAR(8),
+ catalog_kind VARCHAR(20) NOT NULL DEFAULT 'official_bls'
+ CHECK (catalog_kind IN ('official_bls', 'manual_admin', 'manual_user')),
+ owner_profile_id UUID REFERENCES profiles(id) ON DELETE CASCADE,
+ bls_version VARCHAR(16),
+ source VARCHAR(40) NOT NULL DEFAULT 'bls_4.0',
+ is_active BOOLEAN NOT NULL DEFAULT true,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ CONSTRAINT uq_food_catalog_bls_code UNIQUE (bls_code),
+ CONSTRAINT chk_food_catalog_bls_code CHECK (
+ (catalog_kind = 'official_bls' AND bls_code IS NOT NULL)
+ OR (catalog_kind <> 'official_bls' AND bls_code IS NULL)
+ )
+);
+
+CREATE INDEX IF NOT EXISTS idx_food_catalog_name_de ON food_catalog (lower(name_de));
+CREATE INDEX IF NOT EXISTS idx_food_catalog_kind ON food_catalog (catalog_kind);
+CREATE INDEX IF NOT EXISTS idx_food_catalog_owner ON food_catalog (owner_profile_id)
+ WHERE owner_profile_id IS NOT NULL;
+
+CREATE TABLE IF NOT EXISTS food_attribute_values (
+ id BIGSERIAL PRIMARY KEY,
+ food_id UUID NOT NULL REFERENCES food_catalog(id) ON DELETE CASCADE,
+ attribute_id INT NOT NULL REFERENCES food_attributes(id) ON DELETE CASCADE,
+ value_num DOUBLE PRECISION,
+ value_bool BOOLEAN,
+ value_text TEXT,
+ is_trace BOOLEAN NOT NULL DEFAULT false,
+ origin_code VARCHAR(80),
+ reference_text TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ CONSTRAINT uq_food_attribute_value UNIQUE (food_id, attribute_id),
+ CONSTRAINT chk_food_attr_one_value CHECK (
+ (
+ (value_num IS NOT NULL)::int
+ + (value_bool IS NOT NULL)::int
+ + (value_text IS NOT NULL)::int
+ ) <= 1
+ )
+);
+
+CREATE INDEX IF NOT EXISTS idx_fav_food ON food_attribute_values (food_id);
+CREATE INDEX IF NOT EXISTS idx_fav_attr ON food_attribute_values (attribute_id);
+
+CREATE TABLE IF NOT EXISTS food_name_mappings (
+ id SERIAL PRIMARY KEY,
+ source_system VARCHAR(20) NOT NULL DEFAULT 'fddb',
+ source_name_raw VARCHAR(500) NOT NULL,
+ source_name_normalized VARCHAR(500) NOT NULL,
+ food_id UUID NOT NULL REFERENCES food_catalog(id) ON DELETE CASCADE,
+ profile_id UUID REFERENCES profiles(id) ON DELETE CASCADE,
+ source VARCHAR(20) NOT NULL DEFAULT 'manual'
+ CHECK (source IN ('manual', 'bulk', 'admin')),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS uq_food_map_global
+ ON food_name_mappings (source_system, source_name_normalized)
+ WHERE profile_id IS NULL;
+CREATE UNIQUE INDEX IF NOT EXISTS uq_food_map_user
+ ON food_name_mappings (source_system, source_name_normalized, profile_id)
+ WHERE profile_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_food_map_food ON food_name_mappings (food_id);
+
+CREATE TABLE IF NOT EXISTS nutrition_items (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
+ date DATE NOT NULL,
+ logged_at TIMESTAMPTZ,
+ source_name_raw VARCHAR(500) NOT NULL,
+ source_name_normalized VARCHAR(500) NOT NULL,
+ source_system VARCHAR(20) NOT NULL DEFAULT 'fddb',
+ quantity_raw VARCHAR(80),
+ quantity_g NUMERIC(10,3),
+ fddb_kcal NUMERIC(8,2),
+ fddb_protein_g NUMERIC(8,2),
+ fddb_fat_g NUMERIC(8,2),
+ fddb_carbs_g NUMERIC(8,2),
+ food_id UUID REFERENCES food_catalog(id) ON DELETE SET NULL,
+ mapping_id INT REFERENCES food_name_mappings(id) ON DELETE SET NULL,
+ value_origin VARCHAR(20) NOT NULL DEFAULT 'fddb'
+ CHECK (value_origin IN ('fddb', 'bls', 'manual_catalog')),
+ source VARCHAR(20) NOT NULL DEFAULT 'csv'
+ CHECK (source IN ('csv', 'manual')),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_nutrition_items_profile_date
+ ON nutrition_items (profile_id, date DESC);
+CREATE INDEX IF NOT EXISTS idx_nutrition_items_unmapped
+ ON nutrition_items (profile_id, source_name_normalized)
+ WHERE food_id IS NULL;
+
+CREATE TABLE IF NOT EXISTS nutrition_daily_nutrients (
+ id BIGSERIAL PRIMARY KEY,
+ profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
+ date DATE NOT NULL,
+ attribute_id INT NOT NULL REFERENCES food_attributes(id) ON DELETE CASCADE,
+ value DOUBLE PRECISION NOT NULL,
+ contributing_item_count INT NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ CONSTRAINT uq_nutrition_daily_nutrient UNIQUE (profile_id, date, attribute_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_ndn_profile_date
+ ON nutrition_daily_nutrients (profile_id, date DESC);
+
+CREATE TABLE IF NOT EXISTS nutrition_day_marks (
+ id SERIAL PRIMARY KEY,
+ profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
+ date DATE NOT NULL,
+ mark_type VARCHAR(20) NOT NULL CHECK (mark_type IN ('fasting', 'incomplete')),
+ note TEXT,
+ source VARCHAR(20) NOT NULL DEFAULT 'manual',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ CONSTRAINT uq_nutrition_day_mark UNIQUE (profile_id, date)
+);
+
+ALTER TABLE nutrition_log ADD COLUMN IF NOT EXISTS macro_origin VARCHAR(20);
+ALTER TABLE nutrition_log ADD COLUMN IF NOT EXISTS mapped_item_count INT;
+ALTER TABLE nutrition_log ADD COLUMN IF NOT EXISTS unmapped_item_count INT;
+ALTER TABLE nutrition_log ADD COLUMN IF NOT EXISTS has_items BOOLEAN DEFAULT false;
+ALTER TABLE nutrition_log ADD COLUMN IF NOT EXISTS last_import_at TIMESTAMPTZ;
+ALTER TABLE nutrition_log ADD COLUMN IF NOT EXISTS macros_confirmed_at TIMESTAMPTZ;
+
+DELETE FROM nutrition_log a
+USING nutrition_log b
+WHERE a.profile_id = b.profile_id
+ AND a.date = b.date
+ AND (a.created < b.created OR (a.created = b.created AND a.id::text < b.id::text));
+
+CREATE UNIQUE INDEX IF NOT EXISTS uq_nutrition_log_profile_date
+ ON nutrition_log (profile_id, date);
+
+ALTER TABLE profiles
+ ADD COLUMN IF NOT EXISTS nutrition_import_conflict_policy VARCHAR(30) DEFAULT 'prompt';
+
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_constraint WHERE conname = 'chk_nutrition_import_policy'
+ ) THEN
+ ALTER TABLE profiles ADD CONSTRAINT chk_nutrition_import_policy
+ CHECK (nutrition_import_conflict_policy IN (
+ 'prompt', 'overwrite_catalog', 'overwrite_fddb', 'keep_existing'
+ ));
+ END IF;
+END $$;
+
+UPDATE csv_field_mappings
+SET field_mappings = COALESCE(field_mappings, '{}'::jsonb)
+ || '{"bezeichnung": "food_name", "menge": "quantity_raw"}'::jsonb,
+ updated_at = NOW()
+WHERE is_system = true
+ AND module = 'nutrition'
+ AND (
+ mapping_name ILIKE '%FDDB%'
+ OR mapping_name ILIKE '%fddb%'
+ );
+
+COMMENT ON TABLE food_catalog IS 'BLS 4.0 official foods + manual catalog extensions';
+COMMENT ON COLUMN food_catalog.bls_code IS 'Stable official BLS identity; upsert key';
+COMMENT ON TABLE food_name_mappings IS 'Learned FDDB name -> food_catalog; user overrides global';
+COMMENT ON TABLE nutrition_day_marks IS 'Explicit fasting/incomplete; import must not delete';
+
+DO $$
+BEGIN
+ RAISE NOTICE 'Migration 062: BLS food catalog, mappings, nutrition items, day marks';
+END $$;
diff --git a/backend/models.py b/backend/models.py
index 8adac6a..7e21eab 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -30,6 +30,7 @@ class ProfileUpdate(BaseModel):
goal_bf_pct: Optional[float] = None
quality_filter_level: Optional[str] = None # Issue #31: Global quality filter
email: Optional[str] = None # Self-service; leer = entfernen; Änderung setzt Verifikation zurück
+ nutrition_import_conflict_policy: Optional[str] = None
# ── Tracking Models ───────────────────────────────────────────────────────────
diff --git a/backend/requirements.txt b/backend/requirements.txt
index d435df3..3b07881 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -12,3 +12,4 @@ python-dateutil==2.9.0
tzdata>=2024.1 # ZoneInfo (Europe/Berlin) auch unter Windows
matplotlib==3.8.4
reportlab==4.2.0
+openpyxl==3.1.5
diff --git a/backend/routers/admin_bls.py b/backend/routers/admin_bls.py
new file mode 100644
index 0000000..b378418
--- /dev/null
+++ b/backend/routers/admin_bls.py
@@ -0,0 +1,234 @@
+"""Admin BLS catalog import and attribute/food maintenance."""
+from __future__ import annotations
+
+from typing import Optional
+
+from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
+from pydantic import BaseModel
+
+from auth import require_admin
+from bls.import_service import upsert_attributes, upsert_foods
+from bls.parser import parse_components_xlsx, parse_foods_xlsx
+from db import get_cursor, get_db, r2d
+
+router = APIRouter(prefix="/api/admin/bls", tags=["admin", "bls"])
+
+
+class AttributeCreate(BaseModel):
+ attr_key: str
+ name_de: str
+ name_en: Optional[str] = None
+ unit: Optional[str] = None
+ category: Optional[str] = None
+ data_type: str = "num_per_100g"
+ enum_values: Optional[list] = None
+
+
+class ManualFoodCreate(BaseModel):
+ name_de: str
+ name_en: Optional[str] = None
+ macros_per_100g: Optional[dict] = None
+
+
+@router.get("/status")
+def bls_status(session: dict = Depends(require_admin)):
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT COUNT(*) AS n FROM food_catalog WHERE catalog_kind = 'official_bls'")
+ foods = cur.fetchone()["n"]
+ cur.execute("SELECT COUNT(*) AS n FROM food_attributes WHERE origin = 'official_bls'")
+ attrs = cur.fetchone()["n"]
+ cur.execute("SELECT COUNT(*) AS n FROM food_catalog WHERE catalog_kind <> 'official_bls'")
+ manual = cur.fetchone()["n"]
+ cur.execute("SELECT MAX(updated_at) AS last_updated FROM food_catalog WHERE catalog_kind = 'official_bls'")
+ last = cur.fetchone()["last_updated"]
+ return {
+ "official_foods": foods,
+ "official_attributes": attrs,
+ "manual_foods": manual,
+ "last_updated": last,
+ "source": "Max Rubner-Institut, BLS 4.0 (frei verfügbar)",
+ }
+
+
+@router.post("/import/components")
+async def import_components(
+ file: UploadFile = File(...),
+ dry_run: bool = True,
+ session: dict = Depends(require_admin),
+):
+ raw = await file.read()
+ if not raw:
+ raise HTTPException(400, "Leere Datei")
+ try:
+ attrs = parse_components_xlsx(raw)
+ except Exception as e:
+ raise HTTPException(400, f"Components-Datei unlesbar: {e}") from e
+ if dry_run:
+ return {"dry_run": True, "attributes": len(attrs), "sample": attrs[:8]}
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ stats = upsert_attributes(cur, attrs)
+ return {"dry_run": False, **stats}
+
+
+@router.post("/import/foods")
+async def import_foods(
+ file: UploadFile = File(...),
+ dry_run: bool = True,
+ session: dict = Depends(require_admin),
+):
+ raw = await file.read()
+ if not raw:
+ raise HTTPException(400, "Leere Datei")
+ try:
+ parsed = parse_foods_xlsx(raw)
+ except Exception as e:
+ raise HTTPException(400, f"Datendatei unlesbar: {e}") from e
+ foods = parsed["foods"]
+ if dry_run:
+ return {
+ "dry_run": True,
+ "foods": len(foods),
+ "attribute_columns": len(parsed["attribute_headers"]),
+ "sample": [
+ {"bls_code": f["bls_code"], "name_de": f["name_de"]}
+ for f in foods[:8]
+ ],
+ }
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ stats = upsert_foods(cur, foods)
+ return {"dry_run": False, **stats}
+
+
+@router.get("/foods")
+def admin_list_foods(
+ q: Optional[str] = None,
+ kind: Optional[str] = None,
+ limit: int = 50,
+ session: dict = Depends(require_admin),
+):
+ limit = min(max(limit, 1), 200)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ conds = ["is_active = true"]
+ params: list = []
+ if kind:
+ conds.append("catalog_kind = %s")
+ params.append(kind)
+ if q:
+ conds.append("(name_de ILIKE %s OR COALESCE(name_en,'') ILIKE %s OR COALESCE(bls_code,'') ILIKE %s)")
+ like = f"%{q}%"
+ params.extend([like, like, like])
+ params.append(limit)
+ cur.execute(
+ f"""
+ SELECT id, bls_code, name_de, name_en, catalog_kind, food_group, bls_version
+ FROM food_catalog
+ WHERE {' AND '.join(conds)}
+ ORDER BY name_de
+ LIMIT %s
+ """,
+ params,
+ )
+ return [r2d(r) for r in cur.fetchall()]
+
+
+@router.get("/foods/{food_id}")
+def admin_food_detail(food_id: str, session: dict = Depends(require_admin)):
+ from data_layer.nutrition_items import resolve_food_attributes
+
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT * FROM food_catalog WHERE id = %s", (food_id,))
+ row = cur.fetchone()
+ if not row:
+ raise HTTPException(404, "Lebensmittel nicht gefunden")
+ attrs = resolve_food_attributes(cur, food_id)
+ return {**r2d(row), "attributes": attrs}
+
+
+@router.post("/foods/manual")
+def admin_create_manual_food(body: ManualFoodCreate, session: dict = Depends(require_admin)):
+ from data_layer.food_mapping import normalize_food_name
+
+ name = (body.name_de or "").strip()
+ if not name:
+ raise HTTPException(400, "Name fehlt")
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ """
+ INSERT INTO food_catalog (name_de, name_en, catalog_kind, source, external_key)
+ VALUES (%s, %s, 'manual_admin', 'manual', %s)
+ RETURNING *
+ """,
+ (name, body.name_en, f"man-admin-{normalize_food_name(name)[:40]}"),
+ )
+ food = r2d(cur.fetchone())
+ _write_manual_macros(cur, food["id"], body.macros_per_100g)
+ return food
+
+
+@router.get("/attributes")
+def admin_list_attributes(session: dict = Depends(require_admin)):
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ """
+ SELECT id, attr_key, name_de, name_en, unit, category, data_type, origin, sort_order
+ FROM food_attributes
+ WHERE is_active = true
+ ORDER BY origin, sort_order, attr_key
+ """
+ )
+ return [r2d(r) for r in cur.fetchall()]
+
+
+@router.post("/attributes")
+def admin_create_attribute(body: AttributeCreate, session: dict = Depends(require_admin)):
+ key = body.attr_key.strip().upper().replace(" ", "_")
+ if not key:
+ raise HTTPException(400, "attr_key fehlt")
+ if body.data_type not in ("num_per_100g", "boolean", "text", "enum"):
+ raise HTTPException(400, "Ungültiger data_type")
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ try:
+ cur.execute(
+ """
+ INSERT INTO food_attributes
+ (attr_key, name_de, name_en, unit, category, data_type, enum_values, origin, sort_order)
+ VALUES (%s, %s, %s, %s, %s, %s, %s, 'extension', 9000)
+ RETURNING *
+ """,
+ (
+ key, body.name_de, body.name_en, body.unit, body.category,
+ body.data_type, None if not body.enum_values else body.enum_values,
+ ),
+ )
+ except Exception as e:
+ raise HTTPException(409, f"Attribut existiert bereits oder ist ungültig: {e}") from e
+ return r2d(cur.fetchone())
+
+
+def _write_manual_macros(cur, food_id: str, macros: dict | None) -> None:
+ if not macros:
+ return
+ mapping = {"kcal": "ENERCC", "protein_g": "PROT625", "fat_g": "FAT", "carbs_g": "CHO"}
+ for field, key in mapping.items():
+ if field not in macros or macros[field] is None:
+ continue
+ cur.execute("SELECT id FROM food_attributes WHERE attr_key = %s", (key,))
+ row = cur.fetchone()
+ if not row:
+ continue
+ cur.execute(
+ """
+ INSERT INTO food_attribute_values (food_id, attribute_id, value_num, is_trace)
+ VALUES (%s, %s, %s, false)
+ ON CONFLICT (food_id, attribute_id) DO UPDATE SET value_num = EXCLUDED.value_num, updated_at = NOW()
+ """,
+ (food_id, row["id"], float(macros[field])),
+ )
diff --git a/backend/routers/admin_food_mappings.py b/backend/routers/admin_food_mappings.py
new file mode 100644
index 0000000..cdd8459
--- /dev/null
+++ b/backend/routers/admin_food_mappings.py
@@ -0,0 +1,103 @@
+"""Admin CRUD for food_name_mappings."""
+from __future__ import annotations
+
+from typing import Optional
+
+from fastapi import APIRouter, Depends, HTTPException
+from pydantic import BaseModel
+
+from auth import require_admin
+from data_layer.food_mapping import upsert_food_mapping
+from db import get_cursor, get_db, r2d
+
+router = APIRouter(prefix="/api/admin/food-mappings", tags=["admin", "food-mappings"])
+
+
+class FoodMappingCreate(BaseModel):
+ source_name: str
+ food_id: str
+ profile_id: Optional[str] = None
+ source_system: str = "fddb"
+
+
+@router.get("")
+def list_food_mappings(
+ profile_id: Optional[str] = None,
+ global_only: bool = False,
+ session: dict = Depends(require_admin),
+):
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ q = """
+ SELECT m.id, m.source_name_raw, m.source_name_normalized, m.food_id,
+ m.profile_id, m.source, m.created_at, m.updated_at,
+ 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
+ """
+ conds, params = [], []
+ if global_only:
+ conds.append("m.profile_id IS NULL")
+ elif profile_id:
+ conds.append("m.profile_id = %s")
+ params.append(profile_id)
+ if conds:
+ q += " WHERE " + " AND ".join(conds)
+ q += " ORDER BY m.source_name_normalized"
+ cur.execute(q, params)
+ return [r2d(r) for r in cur.fetchall()]
+
+
+@router.get("/stats/coverage")
+def mapping_coverage(session: dict = Depends(require_admin)):
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ """
+ SELECT
+ COUNT(*) AS total_items,
+ COUNT(food_id) AS mapped_items,
+ COUNT(*) - COUNT(food_id) AS unmapped_items,
+ COUNT(DISTINCT source_name_normalized) AS unique_names,
+ COUNT(DISTINCT CASE WHEN food_id IS NULL THEN source_name_normalized END) AS unmapped_names
+ FROM nutrition_items
+ """
+ )
+ return r2d(cur.fetchone())
+
+
+@router.post("")
+def create_food_mapping(body: FoodMappingCreate, session: dict = Depends(require_admin)):
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT id FROM food_catalog WHERE id = %s", (body.food_id,))
+ if not cur.fetchone():
+ raise HTTPException(404, "Lebensmittel nicht gefunden")
+ mid = upsert_food_mapping(
+ cur,
+ source_name_raw=body.source_name,
+ food_id=body.food_id,
+ profile_id=body.profile_id or None,
+ source="admin",
+ source_system=body.source_system,
+ )
+ cur.execute(
+ """
+ SELECT m.*, f.name_de AS food_name_de, f.bls_code
+ FROM food_name_mappings m
+ JOIN food_catalog f ON f.id = m.food_id
+ WHERE m.id = %s
+ """,
+ (mid,),
+ )
+ return r2d(cur.fetchone())
+
+
+@router.delete("/{mapping_id}")
+def delete_food_mapping(mapping_id: int, session: dict = Depends(require_admin)):
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("DELETE FROM food_name_mappings WHERE id = %s RETURNING id", (mapping_id,))
+ if not cur.fetchone():
+ raise HTTPException(404, "Mapping nicht gefunden")
+ return {"ok": True}
diff --git a/backend/routers/bls.py b/backend/routers/bls.py
new file mode 100644
index 0000000..10a6029
--- /dev/null
+++ b/backend/routers/bls.py
@@ -0,0 +1,146 @@
+"""Authenticated catalog search and user-owned foods / mappings."""
+from __future__ import annotations
+
+from typing import Optional
+
+from fastapi import APIRouter, Depends, HTTPException
+from pydantic import BaseModel
+
+from auth import require_auth
+from data_layer.food_mapping import (
+ apply_mapping_to_items,
+ clear_mapping_from_items,
+ normalize_food_name,
+ suggest_catalog_foods,
+ upsert_food_mapping,
+)
+from data_layer.nutrition_items import dates_for_normalized_name, rebuild_daily_nutrients
+from db import get_cursor, get_db, r2d
+from routers.profiles import get_pid
+
+router = APIRouter(prefix="/api/bls", tags=["bls"])
+
+
+class UserFoodCreate(BaseModel):
+ name_de: str
+ name_en: Optional[str] = None
+ macros_per_100g: Optional[dict] = None
+
+
+class MappingUpsert(BaseModel):
+ source_name: str
+ food_id: str
+ source_system: str = "fddb"
+
+
+@router.get("/foods")
+def search_foods(
+ q: str = "",
+ limit: int = 20,
+ session: dict = Depends(require_auth),
+):
+ pid = session["profile_id"]
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ return suggest_catalog_foods(cur, q, pid, limit=min(max(limit, 1), 50))
+
+
+@router.post("/foods/manual")
+def create_user_food(body: UserFoodCreate, session: dict = Depends(require_auth)):
+ from routers.admin_bls import _write_manual_macros
+
+ pid = session["profile_id"]
+ name = (body.name_de or "").strip()
+ if not name:
+ raise HTTPException(400, "Name fehlt")
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ """
+ INSERT INTO food_catalog
+ (name_de, name_en, catalog_kind, owner_profile_id, source, external_key)
+ VALUES (%s, %s, 'manual_user', %s, 'manual', %s)
+ RETURNING *
+ """,
+ (name, body.name_en, pid, f"man-user-{normalize_food_name(name)[:40]}"),
+ )
+ food = r2d(cur.fetchone())
+ _write_manual_macros(cur, food["id"], body.macros_per_100g)
+ return food
+
+
+@router.get("/mappings")
+def list_my_mappings(session: dict = Depends(require_auth)):
+ pid = session["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,
+ 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
+ WHERE m.profile_id = %s
+ ORDER BY m.source_name_normalized
+ """,
+ (pid,),
+ )
+ return [r2d(r) for r in cur.fetchall()]
+
+
+@router.post("/mappings")
+def upsert_my_mapping(body: MappingUpsert, session: dict = Depends(require_auth)):
+ pid = session["profile_id"]
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ """
+ SELECT id FROM food_catalog
+ WHERE id = %s AND is_active = true
+ AND (owner_profile_id IS NULL OR owner_profile_id = %s)
+ """,
+ (body.food_id, pid),
+ )
+ if not cur.fetchone():
+ raise HTTPException(404, "Lebensmittel nicht gefunden")
+ mid = upsert_food_mapping(
+ cur,
+ source_name_raw=body.source_name,
+ food_id=body.food_id,
+ profile_id=pid,
+ source="bulk",
+ source_system=body.source_system,
+ )
+ 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)
+ 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"]
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ """
+ SELECT source_name_normalized FROM food_name_mappings
+ WHERE id = %s AND profile_id = %s
+ """,
+ (mapping_id, pid),
+ )
+ row = cur.fetchone()
+ if not row:
+ raise HTTPException(404, "Mapping nicht gefunden")
+ norm = row["source_name_normalized"]
+ 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)
+ return {"ok": True}
+
+
+# keep get_pid imported for consistency with other routers
+_ = get_pid
diff --git a/backend/routers/nutrition.py b/backend/routers/nutrition.py
index 6496935..4ebde2d 100644
--- a/backend/routers/nutrition.py
+++ b/backend/routers/nutrition.py
@@ -31,8 +31,13 @@ def _pf(s):
# ── Endpoints ─────────────────────────────────────────────────────────────────
@router.post("/import-csv")
-async def import_nutrition_csv(file: UploadFile=File(...), x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
- """Import FDDB nutrition CSV."""
+async def import_nutrition_csv(
+ file: UploadFile = File(...),
+ overwrite_existing: bool = False,
+ x_profile_id: Optional[str] = Header(default=None),
+ session: dict = Depends(require_auth),
+):
+ """Import FDDB nutrition CSV (optional item persist + conflict policy)."""
pid = get_pid(x_profile_id)
# Phase 4: Check feature access and ENFORCE
@@ -56,48 +61,97 @@ async def import_nutrition_csv(file: UploadFile=File(...), x_profile_id: Optiona
except: text = raw.decode('latin-1')
if text.startswith('\ufeff'): text = text[1:]
if not text.strip(): raise HTTPException(400,"Leere Datei")
+ from data_layer.nutrition_items import get_import_policy, replace_csv_items_for_dates
+
+ overwrite = bool(overwrite_existing)
reader = csv.DictReader(io.StringIO(text), delimiter=';')
+ item_rows = []
days: dict = {}
count = 0
for row in reader:
rd = row.get('datum_tag_monat_jahr_stunde_minute','').strip().strip('"')
if not rd: continue
try:
- p = rd.split(' ')[0].split('.')
+ parts = rd.split(' ')
+ p = parts[0].split('.')
iso = f"{p[2]}-{p[1]}-{p[0]}"
- except: continue
- days.setdefault(iso,{'kcal':0,'fat_g':0,'carbs_g':0,'protein_g':0})
- days[iso]['kcal'] += _pf(row.get('kj',0))/4.184
- days[iso]['fat_g'] += _pf(row.get('fett_g',0))
- days[iso]['carbs_g'] += _pf(row.get('kh_g',0))
- days[iso]['protein_g'] += _pf(row.get('protein_g',0))
- count+=1
- inserted=0
- new_entries=0
+ logged_at = None
+ if len(parts) > 1:
+ try:
+ logged_at = datetime.strptime(rd.strip(), '%d.%m.%Y %H:%M')
+ except ValueError:
+ logged_at = None
+ except Exception:
+ continue
+ kcal = _pf(row.get('kj', 0)) / 4.184
+ fat = _pf(row.get('fett_g', 0))
+ carbs = _pf(row.get('kh_g', 0))
+ prot = _pf(row.get('protein_g', 0))
+ days.setdefault(iso, {'kcal': 0, 'fat_g': 0, 'carbs_g': 0, 'protein_g': 0})
+ days[iso]['kcal'] += kcal
+ days[iso]['fat_g'] += fat
+ days[iso]['carbs_g'] += carbs
+ days[iso]['protein_g'] += prot
+ name = (row.get('bezeichnung') or '').strip().strip('"')
+ if name:
+ item_rows.append({
+ "date": iso,
+ "logged_at": logged_at,
+ "food_name": name,
+ "quantity_raw": (row.get('menge') or '').strip() or None,
+ "kcal": kcal,
+ "protein_g": prot,
+ "fat_g": fat,
+ "carbs_g": carbs,
+ })
+ count += 1
+ inserted = 0
+ new_entries = 0
+ ingest_result = {"conflicts": [], "items_written": 0, "policy": "prompt"}
with get_db() as conn:
cur = get_cursor(conn)
- for iso,vals in days.items():
- kcal=round(vals['kcal'],1); fat=round(vals['fat_g'],1)
- carbs=round(vals['carbs_g'],1); prot=round(vals['protein_g'],1)
- cur.execute("SELECT id FROM nutrition_log WHERE profile_id=%s AND date=%s",(pid,iso))
- is_new = not cur.fetchone()
- if not is_new:
- # UPDATE existing
- cur.execute("UPDATE nutrition_log SET kcal=%s,protein_g=%s,fat_g=%s,carbs_g=%s WHERE profile_id=%s AND date=%s",
- (kcal,prot,fat,carbs,pid,iso))
- else:
- # INSERT new
- cur.execute("INSERT INTO nutrition_log (id,profile_id,date,kcal,protein_g,fat_g,carbs_g,source,created) VALUES (%s,%s,%s,%s,%s,%s,%s,'csv',CURRENT_TIMESTAMP)",
- (str(uuid.uuid4()),pid,iso,kcal,prot,fat,carbs))
- new_entries += 1
- inserted+=1
+ policy = get_import_policy(cur, pid)
+ override = "overwrite_catalog" if overwrite else None
+ if item_rows:
+ ingest_result = replace_csv_items_for_dates(
+ cur, pid, item_rows, policy=policy, policy_override=override,
+ )
+ new_entries = ingest_result.get("new_log_days") or 0
+ inserted = ingest_result.get("days_written") or 0
+ else:
+ for iso, vals in days.items():
+ kcal = round(vals['kcal'], 1)
+ fat = round(vals['fat_g'], 1)
+ carbs = round(vals['carbs_g'], 1)
+ prot = round(vals['protein_g'], 1)
+ cur.execute("SELECT id FROM nutrition_log WHERE profile_id=%s AND date=%s", (pid, iso))
+ is_new = not cur.fetchone()
+ if not is_new:
+ if policy in ("overwrite_catalog", "overwrite_fddb") or overwrite:
+ cur.execute(
+ "UPDATE nutrition_log SET kcal=%s,protein_g=%s,fat_g=%s,carbs_g=%s,source='csv',macro_origin='fddb' WHERE profile_id=%s AND date=%s",
+ (kcal, prot, fat, carbs, pid, iso),
+ )
+ else:
+ cur.execute(
+ "INSERT INTO nutrition_log (id,profile_id,date,kcal,protein_g,fat_g,carbs_g,source,macro_origin,created) VALUES (%s,%s,%s,%s,%s,%s,%s,'csv','fddb',CURRENT_TIMESTAMP)",
+ (str(uuid.uuid4()), pid, iso, kcal, prot, fat, carbs),
+ )
+ new_entries += 1
+ inserted += 1
- # Phase 2: Increment usage counter for each new entry created
for _ in range(new_entries):
increment_feature_usage(pid, 'nutrition_entries')
- return {"rows_parsed":count,"days_imported":inserted,"new_entries":new_entries,
- "date_range":{"from":min(days) if days else None,"to":max(days) if days else None}}
+ return {
+ "rows_parsed": count,
+ "days_imported": inserted,
+ "new_entries": new_entries,
+ "items_written": ingest_result.get("items_written", 0),
+ "conflicts": ingest_result.get("conflicts") or [],
+ "policy": ingest_result.get("policy", "prompt"),
+ "date_range": {"from": min(days) if days else None, "to": max(days) if days else None},
+ }
@router.post("")
@@ -122,7 +176,7 @@ def create_nutrition(date: str, kcal: float, protein_g: float, fat_g: float, car
# UPDATE existing entry
cur.execute("""
UPDATE nutrition_log
- SET kcal=%s, protein_g=%s, fat_g=%s, carbs_g=%s, source='manual'
+ SET kcal=%s, protein_g=%s, fat_g=%s, carbs_g=%s, source='manual', macro_origin='manual'
WHERE id=%s AND profile_id=%s
""", (round(kcal,1), round(protein_g,1), round(fat_g,1), round(carbs_g,1), existing['id'], pid))
return {"success": True, "mode": "updated", "id": existing['id']}
@@ -145,8 +199,8 @@ def create_nutrition(date: str, kcal: float, protein_g: float, fat_g: float, car
# INSERT new entry
new_id = str(uuid.uuid4())
cur.execute("""
- INSERT INTO nutrition_log (id, profile_id, date, kcal, protein_g, fat_g, carbs_g, source, created)
- VALUES (%s, %s, %s, %s, %s, %s, %s, 'manual', CURRENT_TIMESTAMP)
+ INSERT INTO nutrition_log (id, profile_id, date, kcal, protein_g, fat_g, carbs_g, source, macro_origin, created)
+ VALUES (%s, %s, %s, %s, %s, %s, %s, 'manual', 'manual', CURRENT_TIMESTAMP)
""", (new_id, pid, date, round(kcal,1), round(protein_g,1), round(fat_g,1), round(carbs_g,1)))
# Phase 2: Increment usage counter
@@ -162,7 +216,17 @@ def list_nutrition(limit: int=365, x_profile_id: Optional[str]=Header(default=No
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
- "SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date DESC LIMIT %s", (pid,limit))
+ """
+ SELECT n.*, m.mark_type, m.note AS mark_note
+ FROM nutrition_log n
+ LEFT JOIN nutrition_day_marks m
+ ON m.profile_id = n.profile_id AND m.date = n.date
+ WHERE n.profile_id=%s
+ ORDER BY n.date DESC
+ LIMIT %s
+ """,
+ (pid, limit),
+ )
return [r2d(r) for r in cur.fetchall()]
@@ -228,6 +292,155 @@ def import_history(x_profile_id: Optional[str]=Header(default=None), session: di
return [r2d(r) for r in cur.fetchall()]
+@router.get("/items")
+def list_nutrition_items(
+ date: Optional[str] = None,
+ limit: int = 200,
+ x_profile_id: Optional[str] = Header(default=None),
+ session: dict = Depends(require_auth),
+):
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ if date:
+ cur.execute(
+ """
+ SELECT i.*, f.name_de AS food_name_de, f.bls_code, f.catalog_kind
+ FROM nutrition_items i
+ LEFT JOIN food_catalog f ON f.id = i.food_id
+ WHERE i.profile_id=%s AND i.date=%s
+ ORDER BY i.logged_at NULLS LAST, i.source_name_raw
+ """,
+ (pid, date),
+ )
+ else:
+ cur.execute(
+ """
+ SELECT i.*, f.name_de AS food_name_de, f.bls_code, f.catalog_kind
+ FROM nutrition_items i
+ LEFT JOIN food_catalog f ON f.id = i.food_id
+ WHERE i.profile_id=%s
+ ORDER BY i.date DESC, i.logged_at NULLS LAST
+ LIMIT %s
+ """,
+ (pid, min(limit, 500)),
+ )
+ return [r2d(r) for r in cur.fetchall()]
+
+
+@router.get("/unmapped")
+def list_unmapped_foods(
+ x_profile_id: Optional[str] = Header(default=None),
+ session: dict = Depends(require_auth),
+):
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ """
+ SELECT source_name_raw, source_name_normalized,
+ COUNT(*) AS count, MIN(date) AS first_date, MAX(date) AS last_date
+ FROM nutrition_items
+ WHERE profile_id=%s AND food_id IS NULL
+ GROUP BY source_name_raw, source_name_normalized
+ ORDER BY count DESC, source_name_normalized
+ """,
+ (pid,),
+ )
+ return [r2d(r) for r in cur.fetchall()]
+
+
+@router.post("/import-conflicts/resolve")
+def resolve_import_conflicts(
+ body: dict,
+ x_profile_id: Optional[str] = Header(default=None),
+ session: dict = Depends(require_auth),
+):
+ from data_layer.nutrition_items import (
+ apply_nutrition_day_macros,
+ compute_day_macro_sums,
+ resolve_choice_macros,
+ )
+
+ pid = get_pid(x_profile_id)
+ decisions = body.get("decisions") or []
+ applied = 0
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ for dec in decisions:
+ day = dec.get("date")
+ choice = dec.get("choice")
+ if not day or not choice:
+ continue
+ sums = compute_day_macro_sums(cur, pid, day)
+ if not sums.get("existing") and choice == "existing":
+ continue
+ macros, origin = resolve_choice_macros(sums, choice)
+ apply_nutrition_day_macros(
+ cur, pid, day, macros, macro_origin=origin, source="csv", confirm=True,
+ )
+ applied += 1
+ return {"applied": applied}
+
+
+@router.put("/days/{day}/mark")
+def upsert_day_mark(
+ day: str,
+ body: dict,
+ x_profile_id: Optional[str] = Header(default=None),
+ session: dict = Depends(require_auth),
+):
+ pid = get_pid(x_profile_id)
+ mark_type = (body or {}).get("mark_type")
+ note = (body or {}).get("note")
+ if mark_type not in ("fasting", "incomplete"):
+ raise HTTPException(400, "mark_type muss fasting oder incomplete sein")
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ """
+ INSERT INTO nutrition_day_marks (profile_id, date, mark_type, note, source, updated_at)
+ VALUES (%s, %s, %s, %s, 'manual', NOW())
+ ON CONFLICT (profile_id, date)
+ DO UPDATE SET mark_type = EXCLUDED.mark_type, note = EXCLUDED.note, updated_at = NOW()
+ RETURNING *
+ """,
+ (pid, day, mark_type, note),
+ )
+ return r2d(cur.fetchone())
+
+
+@router.delete("/days/{day}/mark")
+def delete_day_mark(
+ day: str,
+ x_profile_id: Optional[str] = Header(default=None),
+ session: dict = Depends(require_auth),
+):
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ "DELETE FROM nutrition_day_marks WHERE profile_id=%s AND date=%s",
+ (pid, day),
+ )
+ return {"ok": True}
+
+
+@router.get("/marks")
+def list_day_marks(
+ x_profile_id: Optional[str] = Header(default=None),
+ session: dict = Depends(require_auth),
+):
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ "SELECT * FROM nutrition_day_marks WHERE profile_id=%s ORDER BY date DESC",
+ (pid,),
+ )
+ return [r2d(r) for r in cur.fetchall()]
+
+
@router.put("/{entry_id}")
def update_nutrition(entry_id: str, kcal: float, protein_g: float, fat_g: float, carbs_g: float,
x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
@@ -242,7 +455,7 @@ def update_nutrition(entry_id: str, kcal: float, protein_g: float, fat_g: float,
cur.execute("""
UPDATE nutrition_log
- SET kcal=%s, protein_g=%s, fat_g=%s, carbs_g=%s
+ SET kcal=%s, protein_g=%s, fat_g=%s, carbs_g=%s, source='manual', macro_origin='manual'
WHERE id=%s AND profile_id=%s
""", (round(kcal,1), round(protein_g,1), round(fat_g,1), round(carbs_g,1), entry_id, pid))
diff --git a/backend/routers/profiles.py b/backend/routers/profiles.py
index b9937ea..73b4490 100644
--- a/backend/routers/profiles.py
+++ b/backend/routers/profiles.py
@@ -109,9 +109,17 @@ def update_profile(pid: str, p: ProfileUpdate, session=Depends(require_auth)):
data["verification_expires"] = None
nullable_keys = {"goal_weight", "goal_bf_pct", "dob"}
+ allowed_nutrition_policy = {
+ "prompt", "overwrite_catalog", "overwrite_fddb", "keep_existing",
+ }
for k, v in patch.items():
if k == "email":
continue
+ if k == "nutrition_import_conflict_policy":
+ if v not in allowed_nutrition_policy:
+ raise HTTPException(400, "Ungültige Import-Policy")
+ data[k] = v
+ continue
if v is None and k in nullable_keys:
data[k] = None
elif v is not None:
diff --git a/backend/tests/test_bls_parser.py b/backend/tests/test_bls_parser.py
new file mode 100644
index 0000000..74a0f2a
--- /dev/null
+++ b/backend/tests/test_bls_parser.py
@@ -0,0 +1,39 @@
+from io import BytesIO
+
+from openpyxl import Workbook
+
+from bls.parser import parse_components_xlsx, parse_foods_xlsx
+
+
+def _xlsx(rows):
+ wb = Workbook()
+ ws = wb.active
+ for row in rows:
+ ws.append(row)
+ buf = BytesIO()
+ wb.save(buf)
+ return buf.getvalue()
+
+
+def test_parse_components_dynamic():
+ data = _xlsx([
+ ["Code", "Name_DE", "Name_EN", "Einheit"],
+ ["ENERCC", "Energie", "Energy", "kcal/100g"],
+ ["NA", "Natrium", "Sodium", "mg/100g"],
+ ])
+ attrs = parse_components_xlsx(data)
+ keys = {a["attr_key"] for a in attrs}
+ assert "ENERCC" in keys
+ assert "NA" in keys
+
+
+def test_parse_foods_keeps_bls_code():
+ data = _xlsx([
+ ["BLS Code", "Name", "Food name", "ENERCC Energie [kcal/100g]", "ENERCC Herkunft", "ENERCC Referenz"],
+ ["C131000", "Hafer roh", "Oats raw", 350, "Analyse", "MRI"],
+ ])
+ parsed = parse_foods_xlsx(data)
+ assert parsed["foods"][0]["bls_code"] == "C131000"
+ assert parsed["foods"][0]["name_de"] == "Hafer roh"
+ vals = {v["attr_key"]: v["value_num"] for v in parsed["foods"][0]["values"]}
+ assert vals.get("ENERCC") == 350
diff --git a/backend/tests/test_food_mapping.py b/backend/tests/test_food_mapping.py
new file mode 100644
index 0000000..39c11ed
--- /dev/null
+++ b/backend/tests/test_food_mapping.py
@@ -0,0 +1,18 @@
+from data_layer.food_mapping import normalize_food_name, 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%"
+
+
+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 Stück") is None
+
+
+def test_macros_differ_rounds():
+ assert not macros_differ({"kcal": 1.04, "protein_g": 0, "fat_g": 0, "carbs_g": 0}, {"kcal": 1.0, "protein_g": 0, "fat_g": 0, "carbs_g": 0})
+ assert macros_differ({"kcal": 10, "protein_g": 0, "fat_g": 0, "carbs_g": 0}, {"kcal": 11, "protein_g": 0, "fat_g": 0, "carbs_g": 0})
diff --git a/backend/version.py b/backend/version.py
index ea24d89..05b2add 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -7,9 +7,9 @@ Semantic Versioning: MAJOR.MINOR.PATCH
- PATCH: Bugfix, kleine Änderung, Refactor
"""
-APP_VERSION = "0.9u"
-BUILD_DATE = "2026-07-24"
-DB_SCHEMA_VERSION = "20260409c" # 048/049 vitals_baseline.source csv + SAVEPOINT Import
+APP_VERSION = "0.9v"
+BUILD_DATE = "2026-09-12"
+DB_SCHEMA_VERSION = "20260912" # 062 BLS catalog + nutrition items/marks
MODULE_VERSIONS = {
"auth": "1.2.0",
@@ -20,7 +20,8 @@ MODULE_VERSIONS = {
"circumference": "1.0.1",
"caliper": "1.0.1",
"activity": "1.2.1", # Legacy CSV import: activity_entries feature enforcement
- "nutrition": "1.0.3", # FDDB import UI: UsageBadge + blocked import zone
+ "nutrition": "1.1.0", # BLS mapping, items, day marks, import policy
+ "bls": "1.0.0",
"photos": "1.0.0",
"insights": "1.3.0",
"prompts": "1.1.0",
@@ -36,6 +37,15 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "version": "0.9v",
+ "date": "2026-09-12",
+ "changes": [
+ "BLS 4.0 Stammdaten (dynamische Attribute, Upsert über bls_code)",
+ "Lernendes FDDB-Mapping ohne KI, änder- und löschbar",
+ "Optionale nutrition_items, Import-Policy, Fasten-/Lücken-Marken",
+ ],
+ },
{
"version": "0.9u",
"date": "2026-07-24",
diff --git a/docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md b/docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md
index c96fccf..197e626 100644
--- a/docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md
+++ b/docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md
@@ -26,7 +26,7 @@
|---------|--------|
| Hauptnavigation | `frontend/src/config/appNav.js` (`getMainNavItems`) |
| Active-State Bottom + Sidebar | `frontend/src/App.jsx` (`navItemActive`), `frontend/src/components/DesktopSidebar.jsx` |
-| Admin Shell & Routing | `frontend/src/layouts/AdminShell.jsx`, `frontend/src/layouts/RequireAdmin.jsx`, `frontend/src/config/adminNav.js` (`ADMIN_GROUPS`, Hubs) |
+| Admin Shell & Routing | `frontend/src/layouts/AdminShell.jsx`, `frontend/src/layouts/RequireAdmin.jsx`, `frontend/src/config/adminNav.js` (`ADMIN_GROUPS`, Hubs; Gruppe **Ernährung**: BLS-Import, Katalog, Attribute, FDDB-Mappings) |
| Admin-Seiten | `frontend/src/pages/AdminHomePage.jsx`, `AdminGroupHubPage.jsx`, `AdminUsersPage.jsx`, `AdminSystemPage.jsx`, … |
| Einstellungen Profil | `frontend/src/pages/SettingsPage.jsx`, `.settings-page__field` in `app.css` |
| KI-Analyse Layout | `frontend/src/pages/Analysis.jsx` + `.analysis-split*` in `app.css` |
diff --git a/docs/issues/gitea-bls-issue-body.md b/docs/issues/gitea-bls-issue-body.md
new file mode 100644
index 0000000..ffd572c
--- /dev/null
+++ b/docs/issues/gitea-bls-issue-body.md
@@ -0,0 +1,25 @@
+## Ziel
+
+Verlässliche Lebensmittel-Stammdaten (BLS 4.0, frei) plus lernendes FDDB-Mapping. Tagesmakros bleiben First Class.
+
+## Umsetzung (develop)
+
+- Migration 062: Katalog, dynamische Attribute, Mappings, `nutrition_items`, Tages-Rollup, Fasten-/Lücken-Marken, Import-Policy
+- Admin-Gruppe Ernährung, Nutzer-Tab Zuordnen, Settings-Policy, Initialimport-Checkbox
+- Mapping ohne KI, dauerhaft, änder- und löschbar
+- BLS-Code bleibt stabile Identität
+
+## Specs
+
+- `.claude/docs/functional/BLS_FOOD_REFERENCE.md`
+- `.claude/docs/technical/BLS_FOOD_REFERENCE.md`
+- `docs/issues/issue-bls-food-mapping.md`
+
+## Folge
+
+Gitea #75 (Zucker/Ballaststoffe/Qualität, Platzhalter Mikros + Esszeitpunkte)
+
+## Tests
+
+- Unit: `backend/tests/test_food_mapping.py`, `backend/tests/test_bls_parser.py`
+- Playwright: Ernährung-Tabs, Import-Checkbox, Settings-Policy, API unmapped/marks/bls-Suche
diff --git a/docs/issues/issue-bls-food-mapping.md b/docs/issues/issue-bls-food-mapping.md
new file mode 100644
index 0000000..ecfdb7d
--- /dev/null
+++ b/docs/issues/issue-bls-food-mapping.md
@@ -0,0 +1,16 @@
+# BLS-Stammdaten, FDDB-Mapping, Item-Tagebuch
+
+**Status:** in Umsetzung · **Gitea:** [#106](http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/106) · **Folge:** #75 (Zucker/Ballaststoffe/Qualität)
+
+## Ziel
+
+Verlässliche Lebensmittel-Stammdaten (BLS 4.0 + manuelle Erweiterung), lernendes Mapping aus FDDB, optionale Item-Ebene. Tagesmakros bleiben ohne Katalog nutzbar.
+
+## Abnahme Phase 1
+
+- BLS-XLSX-Import (Dry-Run + Apply), Codes bleiben erhalten
+- Manuelle Foods gekennzeichnet
+- Mapping: Lookup, Bestätigen, Ändern, Löschen; keine KI
+- FDDB-Import speichert Zeilen; Makro-Konflikt laut Policy
+- Fasten-/Lücken-Marken unabhängig vom Import
+- Einzelerfassung nur Makros unverändert
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 55ebe0c..a24ce4a 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -57,6 +57,14 @@ import CustomGoalsPage from './pages/CustomGoalsPage'
import UniversalCsvImportPage from './pages/UniversalCsvImportPage'
import AdminCsvTemplatesPage from './pages/AdminCsvTemplatesPage'
import AdminCsvTemplateEditorPage from './pages/AdminCsvTemplateEditorPage'
+import AdminBlsImportPage from './pages/AdminBlsImportPage'
+import AdminBlsFoodsPage from './pages/AdminBlsFoodsPage'
+import AdminFoodMappingsPage from './pages/AdminFoodMappingsPage'
+import AdminFoodAttributesPage from './pages/AdminFoodAttributesPage'
+import AdminBlsImportPage from './pages/AdminBlsImportPage'
+import AdminBlsFoodsPage from './pages/AdminBlsFoodsPage'
+import AdminFoodMappingsPage from './pages/AdminFoodMappingsPage'
+import AdminFoodAttributesPage from './pages/AdminFoodAttributesPage'
import WorkflowEditorPage from './pages/WorkflowEditorPage'
import DesktopSidebar from './components/DesktopSidebar'
import { getMainNavItems } from './config/appNav'
@@ -267,6 +275,10 @@ function AppShell() {
}/>
} />
} />
+ } />
+ } />
+ } />
+ } />
}/>
diff --git a/frontend/src/components/NutritionFoodMap.jsx b/frontend/src/components/NutritionFoodMap.jsx
new file mode 100644
index 0000000..5003490
--- /dev/null
+++ b/frontend/src/components/NutritionFoodMap.jsx
@@ -0,0 +1,144 @@
+import { useEffect, useState } from 'react'
+import { api } from '../utils/api'
+
+export default function NutritionFoodMap({ onChanged }) {
+ const [unmapped, setUnmapped] = useState([])
+ const [learned, setLearned] = useState([])
+ const [error, setError] = useState(null)
+ const [query, setQuery] = useState({})
+ const [hits, setHits] = useState({})
+ const [saving, setSaving] = useState(null)
+
+ const load = async () => {
+ try {
+ const [u, m] = await Promise.all([api.listUnmappedFoods(), api.listMyFoodMappings()])
+ setUnmapped(u)
+ setLearned(m)
+ } catch (e) {
+ setError(e.message)
+ }
+ }
+
+ useEffect(() => { load() }, [])
+
+ const search = async (key, q) => {
+ setQuery((s) => ({ ...s, [key]: q }))
+ if (!q || q.length < 2) {
+ setHits((s) => ({ ...s, [key]: [] }))
+ return
+ }
+ try {
+ const rows = await api.searchBlsFoods(q)
+ setHits((s) => ({ ...s, [key]: rows }))
+ } catch (e) {
+ setError(e.message)
+ }
+ }
+
+ const assign = async (sourceName, foodId, key) => {
+ setSaving(key)
+ setError(null)
+ try {
+ await api.upsertMyFoodMapping({ source_name: sourceName, food_id: foodId })
+ setHits((s) => ({ ...s, [key]: [] }))
+ await load()
+ onChanged?.()
+ } catch (e) {
+ setError(e.message)
+ } finally {
+ setSaving(null)
+ }
+ }
+
+ const remove = async (id) => {
+ if (!confirm('Zuordnung wirklich löschen?')) return
+ try {
+ await api.deleteMyFoodMapping(id)
+ await load()
+ onChanged?.()
+ } catch (e) {
+ setError(e.message)
+ }
+ }
+
+ return (
+
+
Lebensmittel zuordnen
+
+ Einmal bestätigt, bleibt die Zuordnung erhalten und gilt für spätere Importe.
+ Du kannst sie jederzeit ändern oder löschen. Keine automatische KI-Zuordnung.
+
+ Wenn für einen Tag schon Makros existieren: nachfragen, Katalog-/FDDB-Summe schreiben oder die bestehenden Werte behalten.
+ Für den einmaligen FDDB-Reimport kannst du auch die Checkbox am Import nutzen.
+