From 919c77fcf894ac92c19d0a350c2d338036ad94d7 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 12 Sep 2026 14:35:57 +0200 Subject: [PATCH] feat: BLS-Stammdaten, FDDB-Mapping und Item-Tagebuch (#106) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Katalog, lernendes Mapping ohne KI, optionale Items und Import-Policy. Playwright-Smoke und Issue-Audit um Ernährung/Zuordnen/API ergänzt. Co-authored-by: Cursor --- .claude/docs/GITEA_ISSUES_INDEX.md | 10 +- .claude/docs/README.md | 1 + .claude/docs/functional/BLS_FOOD_REFERENCE.md | 21 + .claude/docs/technical/BLS_FOOD_REFERENCE.md | 31 ++ CLAUDE.md | 8 + backend/bls/__init__.py | 1 + backend/bls/import_service.py | 126 +++++ backend/bls/parser.py | 158 +++++++ backend/csv_parser/executor.py | 34 ++ backend/csv_parser/module_registry.py | 2 + backend/data_layer/food_mapping.py | 194 ++++++++ backend/data_layer/nutrition_items.py | 437 ++++++++++++++++++ backend/main.py | 4 + backend/migrations/062_bls_food_catalog.sql | 205 ++++++++ backend/models.py | 1 + backend/requirements.txt | 1 + backend/routers/admin_bls.py | 234 ++++++++++ backend/routers/admin_food_mappings.py | 103 +++++ backend/routers/bls.py | 146 ++++++ backend/routers/nutrition.py | 283 ++++++++++-- backend/routers/profiles.py | 8 + backend/tests/test_bls_parser.py | 39 ++ backend/tests/test_food_mapping.py | 18 + backend/version.py | 18 +- docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md | 2 +- docs/issues/gitea-bls-issue-body.md | 25 + docs/issues/issue-bls-food-mapping.md | 16 + frontend/src/App.jsx | 12 + frontend/src/components/NutritionFoodMap.jsx | 144 ++++++ frontend/src/config/adminNav.js | 27 ++ frontend/src/pages/AdminBlsFoodsPage.jsx | 57 +++ frontend/src/pages/AdminBlsImportPage.jsx | 56 +++ .../src/pages/AdminFoodAttributesPage.jsx | 44 ++ frontend/src/pages/AdminFoodMappingsPage.jsx | 68 +++ frontend/src/pages/NutritionPage.jsx | 65 ++- frontend/src/pages/SettingsPage.jsx | 27 ++ frontend/src/utils/api.js | 36 +- tests/dev-smoke-test.spec.js | 32 ++ tests/issue-audit.spec.js | 1 + 39 files changed, 2648 insertions(+), 47 deletions(-) create mode 100644 .claude/docs/functional/BLS_FOOD_REFERENCE.md create mode 100644 .claude/docs/technical/BLS_FOOD_REFERENCE.md create mode 100644 backend/bls/__init__.py create mode 100644 backend/bls/import_service.py create mode 100644 backend/bls/parser.py create mode 100644 backend/data_layer/food_mapping.py create mode 100644 backend/data_layer/nutrition_items.py create mode 100644 backend/migrations/062_bls_food_catalog.sql create mode 100644 backend/routers/admin_bls.py create mode 100644 backend/routers/admin_food_mappings.py create mode 100644 backend/routers/bls.py create mode 100644 backend/tests/test_bls_parser.py create mode 100644 backend/tests/test_food_mapping.py create mode 100644 docs/issues/gitea-bls-issue-body.md create mode 100644 docs/issues/issue-bls-food-mapping.md create mode 100644 frontend/src/components/NutritionFoodMap.jsx create mode 100644 frontend/src/pages/AdminBlsFoodsPage.jsx create mode 100644 frontend/src/pages/AdminBlsImportPage.jsx create mode 100644 frontend/src/pages/AdminFoodAttributesPage.jsx create mode 100644 frontend/src/pages/AdminFoodMappingsPage.jsx 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. +

+ {error &&
{error}
} + +

Offen ({unmapped.length})

+ {unmapped.length === 0 &&

Keine ungemappten Bezeichner.

} + {unmapped.map((u) => { + const key = u.source_name_normalized + return ( +
+
{u.source_name_raw}
+
+ {u.count}× · {u.first_date} – {u.last_date} +
+ search(key, e.target.value)} + /> + {(hits[key] || []).map((h) => ( + + ))} +
+ ) + })} + +

Gelernt ({learned.length})

+ {learned.map((m) => ( +
+
+
{m.source_name_raw}
+
+ → {m.bls_code ? `${m.bls_code} · ` : ''}{m.food_name_de} + {m.catalog_kind !== 'official_bls' ? ' (manuell)' : ''} +
+
+ +
+ ))} +
+ ) +} + +export function DayMarkButtons({ date, markType, onChanged }) { + const setMark = async (type) => { + try { + if (markType === type) await api.deleteNutritionDayMark(date) + else await api.putNutritionDayMark(date, { mark_type: type }) + onChanged?.() + } catch (e) { + alert(e.message) + } + } + return ( + + + + + ) +} diff --git a/frontend/src/config/adminNav.js b/frontend/src/config/adminNav.js index 848306f..3945ea0 100644 --- a/frontend/src/config/adminNav.js +++ b/frontend/src/config/adminNav.js @@ -115,6 +115,33 @@ export const ADMIN_GROUPS = [ }, ], }, + { + id: 'nutrition', + label: 'Ernährung', + description: 'BLS-Stammdaten, Attribute und FDDB-Mappings.', + items: [ + { + to: '/admin/bls-import', + label: 'BLS importieren', + description: 'Offizielle BLS-4.0-XLSX (Components + Daten).', + }, + { + to: '/admin/bls-foods', + label: 'Lebensmittelkatalog', + description: 'Suche inkl. BLS-Code, manuelle Einträge.', + }, + { + to: '/admin/food-attributes', + label: 'Stoffe & Attribute', + description: 'BLS-Komponenten und Erweiterungen (Histamin, Gluten).', + }, + { + to: '/admin/food-mappings', + label: 'FDDB-Mappings', + description: 'Globale Standardzuordnungen und Coverage.', + }, + ], + }, { id: 'system', label: 'Basiseinstellungen', diff --git a/frontend/src/pages/AdminBlsFoodsPage.jsx b/frontend/src/pages/AdminBlsFoodsPage.jsx new file mode 100644 index 0000000..c2937e9 --- /dev/null +++ b/frontend/src/pages/AdminBlsFoodsPage.jsx @@ -0,0 +1,57 @@ +import { useState } from 'react' +import { api } from '../utils/api' + +export default function AdminBlsFoodsPage() { + const [q, setQ] = useState('') + const [rows, setRows] = useState([]) + const [detail, setDetail] = useState(null) + const [error, setError] = useState(null) + + const search = async () => { + try { + setRows(await api.adminBlsFoods(q)) + } catch (e) { + setError(e.message) + } + } + + return ( +
+

Lebensmittelkatalog

+ {error &&

{error}

} +
+ setQ(e.target.value)} placeholder="Name oder BLS-Code" /> + +
+ {rows.map((r) => ( + + ))} + {detail && ( +
+

{detail.bls_code} {detail.name_de}

+

{detail.catalog_kind} · {detail.bls_version || '—'}

+ + + {(detail.attributes || []).filter((a) => a.value_num != null || a.is_trace).slice(0, 40).map((a) => ( + + + + + + ))} + +
{a.attr_key}{a.name_de}{a.is_trace ? 'Spuren' : a.value_num} {a.unit || ''}
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/AdminBlsImportPage.jsx b/frontend/src/pages/AdminBlsImportPage.jsx new file mode 100644 index 0000000..6caf568 --- /dev/null +++ b/frontend/src/pages/AdminBlsImportPage.jsx @@ -0,0 +1,56 @@ +import { useEffect, useState } from 'react' +import { api } from '../utils/api' + +export default function AdminBlsImportPage() { + const [status, setStatus] = useState(null) + const [msg, setMsg] = useState(null) + const [error, setError] = useState(null) + + const load = () => { + api.adminBlsStatus().then(setStatus).catch((e) => setError(e.message)) + } + useEffect(() => { load() }, []) + + const run = async (kind, file, dry) => { + setError(null) + setMsg(dry ? 'Prüfe…' : 'Importiere…') + try { + const fn = kind === 'components' ? api.adminBlsImportComponents : api.adminBlsImportFoods + const res = await fn(file, dry) + setMsg(JSON.stringify(res, null, 2)) + if (!dry) load() + } catch (e) { + setError(e.message) + setMsg(null) + } + } + + return ( +
+

BLS 4.0 importieren

+

+ Offizielle Dateien von blsdb.de (frei verfügbar, MRI). Codes bleiben erhalten. + Zuerst Components, dann die Datendatei. Immer zuerst „Prüfen“. +

+ {status && ( +

+ {status.official_foods} offizielle Lebensmittel · {status.official_attributes} Stoffe · + {status.manual_foods} manuell · {status.source} +

+ )} + {error &&

{error}

} +
+ + e.target.files[0] && run('components', e.target.files[0], true)} /> + e.target.files[0] && run('components', e.target.files[0], false)} /> +
+

Erstes Feld = prüfen, zweites = anwenden.

+
+ + e.target.files[0] && run('foods', e.target.files[0], true)} /> + e.target.files[0] && run('foods', e.target.files[0], false)} /> +
+ {msg &&
{msg}
} +
+ ) +} diff --git a/frontend/src/pages/AdminFoodAttributesPage.jsx b/frontend/src/pages/AdminFoodAttributesPage.jsx new file mode 100644 index 0000000..e57343e --- /dev/null +++ b/frontend/src/pages/AdminFoodAttributesPage.jsx @@ -0,0 +1,44 @@ +import { useEffect, useState } from 'react' +import { api } from '../utils/api' + +export default function AdminFoodAttributesPage() { + const [rows, setRows] = useState([]) + const [form, setForm] = useState({ attr_key: '', name_de: '', unit: '', data_type: 'num_per_100g' }) + const [error, setError] = useState(null) + + const load = () => api.adminBlsAttributes().then(setRows).catch((e) => setError(e.message)) + useEffect(() => { load() }, []) + + return ( +
+

Stoffe & Attribute

+

+ Offizielle BLS-Codes kommen aus dem Import. Erweiterungen (Histamin, Gluten, …) hier anlegen — ohne Migration. +

+ {error &&

{error}

} +
+ setForm({ ...form, attr_key: e.target.value })} /> + setForm({ ...form, name_de: e.target.value })} /> + setForm({ ...form, unit: e.target.value })} /> + + +
+ {rows.map((r) => ( +
+ {r.attr_key} · {r.name_de} · {r.data_type} · {r.origin} +
+ ))} +
+ ) +} diff --git a/frontend/src/pages/AdminFoodMappingsPage.jsx b/frontend/src/pages/AdminFoodMappingsPage.jsx new file mode 100644 index 0000000..ebbb780 --- /dev/null +++ b/frontend/src/pages/AdminFoodMappingsPage.jsx @@ -0,0 +1,68 @@ +import { useEffect, useState } from 'react' +import { api } from '../utils/api' + +export default function AdminFoodMappingsPage() { + const [rows, setRows] = useState([]) + const [coverage, setCoverage] = useState(null) + const [name, setName] = useState('') + const [foodQ, setFoodQ] = useState('') + const [hits, setHits] = useState([]) + const [foodId, setFoodId] = useState('') + const [error, setError] = useState(null) + + const load = () => { + Promise.all([api.adminListFoodMappings(false), api.adminFoodMappingCoverage()]) + .then(([m, c]) => { setRows(m); setCoverage(c) }) + .catch((e) => setError(e.message)) + } + useEffect(() => { load() }, []) + + return ( +
+

FDDB→BLS Mappings

+ {error &&

{error}

} + {coverage && ( +

+ {coverage.mapped_items}/{coverage.total_items} Zeilen gemappt · + {coverage.unmapped_names} ungemappte Namen +

+ )} +
+ setName(e.target.value)} /> +
+
+ { + setFoodQ(e.target.value) + if (e.target.value.length > 1) setHits(await api.searchBlsFoods(e.target.value)) + }} /> +
+ {hits.map((h) => ( + + ))} + + {rows.map((r) => ( +
+
+ {r.source_name_raw} → {r.bls_code} {r.food_name_de} +
{r.profile_id ? 'user' : 'global'}
+
+ +
+ ))} +
+ ) +} diff --git a/frontend/src/pages/NutritionPage.jsx b/frontend/src/pages/NutritionPage.jsx index cef92e1..4f9a35b 100644 --- a/frontend/src/pages/NutritionPage.jsx +++ b/frontend/src/pages/NutritionPage.jsx @@ -1,6 +1,7 @@ import { useState, useEffect, useRef } from 'react' import { Upload, CheckCircle, TrendingUp, Info } from 'lucide-react' import UsageBadge from '../components/UsageBadge' +import NutritionFoodMap, { DayMarkButtons } from '../components/NutritionFoodMap' import { LineChart, Line, BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend, ReferenceLine, ScatterChart, Scatter @@ -324,9 +325,13 @@ function DataTab({ entries, onUpdate }) { {e.source && (
- Quelle: {e.source} + Quelle: {e.source}{e.macro_origin ? ` · Makros: ${e.macro_origin}` : ''} + {e.mark_type ? ` · ${e.mark_type === 'fasting' ? 'Fastentag' : 'unvollständig'}` : ''}
)} +
+ +
) : ( <> @@ -441,15 +446,18 @@ function ImportPanel({ onImported, usage = null }) { const [dragging,setDragging]= useState(false) const [tab, setTab] = useState('file') // 'file' | 'paste' const [pasteText, setPasteText] = useState('') + const [overwrite, setOverwrite] = useState(false) + const [conflicts, setConflicts] = useState([]) const atLimit = usage && !usage.allowed const runImport = async (file) => { if (atLimit) return - setStatus('loading'); setError(null) + setStatus('loading'); setError(null); setConflicts([]) try { - const result = await nutritionApi.importCsv(file) + const result = await nutritionApi.importCsv(file, overwrite) if (result.days_imported === undefined) throw new Error(JSON.stringify(result)) setStatus(result) + setConflicts(result.conflicts || []) onImported() } catch(err) { setError('Import fehlgeschlagen: ' + err.message) @@ -485,7 +493,12 @@ function ImportPanel({ onImported, usage = null }) {

In FDDB: Mein Tagebuch → Exportieren → CSV — dann hier importieren. + Zeilen (Name, Menge, Uhrzeit) werden gespeichert, wenn die CSV sie enthält.

+ {/* Tab switcher */}
@@ -567,7 +580,7 @@ function ImportPanel({ onImported, usage = null }) {
Import erfolgreich
-
{status.days_imported} Tage importiert · {status.rows_parsed} Einträge verarbeitet
+
{status.days_imported} Tage · {status.rows_parsed} Zeilen{status.items_written ? ` · ${status.items_written} Items` : ''}
{status.date_range?.from && (
{dayjs(status.date_range.from).format('DD.MM.YYYY')} – {dayjs(status.date_range.to).format('DD.MM.YYYY')} @@ -575,6 +588,45 @@ function ImportPanel({ onImported, usage = null }) { )}
)} + {conflicts.length > 0 && ( + { setConflicts([]); onImported() }} /> + )} +
+ ) +} + +function ConflictDialog({ conflicts, onDone }) { + const [choices, setChoices] = useState({}) + const [busy, setBusy] = useState(false) + const setC = (date, choice) => setChoices((s) => ({ ...s, [date]: choice })) + const save = async () => { + setBusy(true) + try { + const decisions = conflicts.map((c) => ({ date: c.date, choice: choices[c.date] || 'existing' })) + await nutritionApi.resolveNutritionConflicts(decisions) + onDone() + } catch (e) { + alert(e.message) + } finally { + setBusy(false) + } + } + return ( +
+ Abweichende Tagesmakros + {conflicts.map((c) => ( +
+
{c.date}
+
Ist {c.existing?.kcal} · FDDB {c.fddb?.kcal} · Katalog {c.catalog?.kcal} kcal
+ {['existing','fddb','catalog'].map((k) => ( + + ))} +
+ ))} +
) } @@ -837,6 +889,9 @@ export default function NutritionPage() { + {/* Entry Form */} @@ -853,6 +908,8 @@ export default function NutritionPage() { )} + {inputTab==='map' && } + {loading &&
} {!loading && !hasData && ( diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index d1cd3c0..e2e2d06 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -458,6 +458,33 @@ export default function SettingsPage() {
+
+
Ernährungs-Import
+

+ 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. +

+ +
+ {/* Auth actions */}
🔐 Konto
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index a565b92..791ff93 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -210,11 +210,43 @@ export const api = { }, // Nutrition - importCsv: async(file)=>{ + importCsv: async(file, overwriteExisting=false)=>{ const fd=new FormData();fd.append('file',file) - const r=await fetch(`${BASE}/nutrition/import-csv`,{method:'POST',body:fd,headers:hdrs()}) + const qs = overwriteExisting ? '?overwrite_existing=true' : '' + const r=await fetch(`${BASE}/nutrition/import-csv${qs}`,{method:'POST',body:fd,headers:hdrs()}) const d=await r.json();if(!r.ok)throw new Error(formatFastApiDetail(d.detail, JSON.stringify(d)));return d }, + listNutritionItems: (date) => req(date ? `/nutrition/items?date=${date}` : '/nutrition/items'), + listUnmappedFoods: () => req('/nutrition/unmapped'), + listNutritionMarks: () => req('/nutrition/marks'), + putNutritionDayMark: (date, d) => req(`/nutrition/days/${date}/mark`, jput(d)), + deleteNutritionDayMark: (date) => req(`/nutrition/days/${date}/mark`, {method:'DELETE'}), + resolveNutritionConflicts: (decisions) => req('/nutrition/import-conflicts/resolve', json({decisions})), + searchBlsFoods: (q, limit=20) => req(`/bls/foods?q=${encodeURIComponent(q||'')}&limit=${limit}`), + createUserFood: (d) => req('/bls/foods/manual', json(d)), + listMyFoodMappings: () => req('/bls/mappings'), + upsertMyFoodMapping: (d) => req('/bls/mappings', json(d)), + deleteMyFoodMapping: (id) => req(`/bls/mappings/${id}`, {method:'DELETE'}), + adminBlsStatus: () => req('/admin/bls/status'), + adminBlsImportComponents: async (file, dryRun=true) => { + const fd=new FormData();fd.append('file',file) + const r=await fetch(`${BASE}/admin/bls/import/components?dry_run=${dryRun}`,{method:'POST',body:fd,headers:hdrs()}) + const d=await r.json();if(!r.ok)throw new Error(formatFastApiDetail(d.detail, JSON.stringify(d)));return d + }, + adminBlsImportFoods: async (file, dryRun=true) => { + const fd=new FormData();fd.append('file',file) + const r=await fetch(`${BASE}/admin/bls/import/foods?dry_run=${dryRun}`,{method:'POST',body:fd,headers:hdrs()}) + const d=await r.json();if(!r.ok)throw new Error(formatFastApiDetail(d.detail, JSON.stringify(d)));return d + }, + adminBlsFoods: (q, kind) => req(`/admin/bls/foods?${q?('q='+encodeURIComponent(q)+'&'):''}${kind?('kind='+kind):''}`), + adminBlsFoodDetail: (id) => req(`/admin/bls/foods/${id}`), + adminCreateManualFood: (d) => req('/admin/bls/foods/manual', json(d)), + adminBlsAttributes: () => req('/admin/bls/attributes'), + adminCreateBlsAttribute: (d) => req('/admin/bls/attributes', json(d)), + adminListFoodMappings: (globalOnly) => req(`/admin/food-mappings${globalOnly?'?global_only=true':''}`), + adminCreateFoodMapping: (d) => req('/admin/food-mappings', json(d)), + adminDeleteFoodMapping: (id) => req(`/admin/food-mappings/${id}`, {method:'DELETE'}), + adminFoodMappingCoverage: () => req('/admin/food-mappings/stats/coverage'), listNutrition: (l=365) => req(`/nutrition?limit=${l}`), nutritionCorrelations: () => req('/nutrition/correlations'), nutritionWeekly: (w=16) => req(`/nutrition/weekly?weeks=${w}`), diff --git a/tests/dev-smoke-test.spec.js b/tests/dev-smoke-test.spec.js index 9926a28..7a11b35 100644 --- a/tests/dev-smoke-test.spec.js +++ b/tests/dev-smoke-test.spec.js @@ -55,3 +55,35 @@ test('5. Keine kritischen Console-Fehler', async ({ page }) => { console.log('Keine kritischen Console-Fehler'); } }); + +test('FEATURE: Ernährung — Einzelerfassung, Import-Policy-Hinweis, Zuordnen', async ({ page }) => { + await page.goto('/nutrition'); + await page.waitForLoadState('networkidle'); + await expect(page.getByRole('button', { name: /Einzelerfassung/i })).toBeVisible(); + await page.getByRole('button', { name: /Import/i }).click(); + await expect(page.locator('.card').filter({ hasText: 'FDDB CSV Import' })).toBeVisible(); + await expect(page.getByText(/Vorhandene Tagesmakros überschreiben/)).toBeVisible(); + await page.getByRole('button', { name: /Zuordnen/i }).click(); + await expect(page.getByText(/Lebensmittel zuordnen/)).toBeVisible(); +}); + +test('FEATURE: Settings — Ernährungs-Import-Policy', async ({ page }) => { + await page.goto('/settings'); + await page.waitForLoadState('networkidle'); + await expect(page.getByText('Ernährungs-Import')).toBeVisible(); + await expect(page.locator('select').filter({ has: page.locator('option[value="prompt"]') })).toBeVisible(); +}); + +test('API: nutrition unmapped + marks erreichbar', async ({ page }) => { + await page.goto('/nutrition'); + await page.waitForLoadState('networkidle'); + const token = await page.evaluate(() => localStorage.getItem('bodytrack_token')); + expect(token).toBeTruthy(); + const headers = { 'X-Auth-Token': token }; + const unmapped = await page.request.get('/api/nutrition/unmapped', { headers }); + expect(unmapped.ok()).toBeTruthy(); + const marks = await page.request.get('/api/nutrition/marks', { headers }); + expect(marks.ok()).toBeTruthy(); + const foods = await page.request.get('/api/bls/foods?q=hafer', { headers }); + expect(foods.ok()).toBeTruthy(); +}); diff --git a/tests/issue-audit.spec.js b/tests/issue-audit.spec.js index b9ea6d3..6bc6f7b 100644 --- a/tests/issue-audit.spec.js +++ b/tests/issue-audit.spec.js @@ -43,6 +43,7 @@ test.describe('Issue-Audit UI', () => { await page.waitForLoadState('networkidle'); const importCard = page.locator('.card').filter({ hasText: 'FDDB CSV Import' }); await expect(importCard).toBeVisible(); + await expect(importCard.getByText(/Vorhandene Tagesmakros überschreiben/)).toBeVisible(); const titleRow = importCard.locator('.card-title.badge-container-right'); if (await titleRow.count()) { await expect(titleRow).toBeVisible();