diff --git a/.claude/docs/functional/BLS_FOOD_REFERENCE.md b/.claude/docs/functional/BLS_FOOD_REFERENCE.md index 7fad517..8a82b22 100644 --- a/.claude/docs/functional/BLS_FOOD_REFERENCE.md +++ b/.claude/docs/functional/BLS_FOOD_REFERENCE.md @@ -46,7 +46,7 @@ Gelernte Zuordnungen und Listen lassen sich als **JSON sichern** und auf einer a ## Manuelle Lebensmittel / Supplemente -Eigene Einträge des Nutzers sind **persönlich** (`manual_user`). Der Admin legt **zentrale** Einträge für alle an (`manual_admin`). Beide speichern **dieselben Stoffwerte** wie BLS-Lebensmittel (`food_attribute_values`, immer pro 100 g). Beim Anlegen: Makros plus Suche nach weiteren Stoffen (EPA, DHA, Omega-3). Etikett „pro 8 ml“ → Portionsgröße in g angeben, Umrechnung auf 100 g erfolgt serverseitig. Einheit des Stoffs beachten (oft **g**, Etikett in mg: 1100 mg = 1,1 g). Fehlt ein Stoff im Katalog: Nutzer kann ihn selbst anlegen oder Admin → Stoffe & Attribute. +Eigene Einträge des Nutzers sind **persönlich** (`manual_user`). Der Admin legt **zentrale** Einträge für alle an (`manual_admin`). Beide speichern **dieselben Stoffwerte** wie BLS-Lebensmittel (`food_attribute_values`, immer pro 100 g). Beim Anlegen: Makros plus Suche nach weiteren Stoffen (EPA, DHA, Omega-3). Etikett „pro 8 ml“ → Portionsgröße in g angeben, Umrechnung auf 100 g erfolgt serverseitig. Einheit des Stoffs beachten (oft **g**, Etikett in mg: 1100 mg = 1,1 g). Fehlt ein Stoff im Katalog: Nutzer kann ihn selbst anlegen oder Admin → Stoffe & Attribute (Bezeichnung änderbar, Schlüssel fest). In die Makro-Haupttabelle (`nutrition_log`) fließen nur ENERCC→kcal, PROT625→protein_g, FAT→fat_g, CHO→carbs_g (Wert je 100 g × gegessene g / 100). Andere Stoffe gehen nach `nutrition_daily_nutrients`. **Nachträglich ändern:** Unter Ernährung → Tab **Lebensmittel** (persönliche Liste + zentrale Admin-Einträge, Suche). Persönliche Einträge legt und ändert der Owner. Zentrale Einträge liest jeder; **ändert nur ein Admin** (gleiche Liste, Zeile oder Bearbeiten). Beim Zuordnen gilt die Reihenfolge persönlich → zentral → BLS. Globale Namens-Mappings (Admin) gelten für alle, dürfen aber nicht auf ein privates Nutzer-Lebensmittel zeigen. Offizielle BLS-Einträge bleiben fest. Beim Speichern gelten die Felder als Werte je 100 g, außer die Portionsgröße wird bewusst auf die Etikett-Portion gesetzt. Betroffene Tagebuch-Tage werden im Hintergrund neu berechnet. Admin → Lebensmittelkatalog → Reiter **Manuell** zeigt **Zentral** und **Persönlich** getrennt; beide sind dort änderbar. diff --git a/.claude/docs/technical/BLS_FOOD_REFERENCE.md b/.claude/docs/technical/BLS_FOOD_REFERENCE.md index c399f86..62bb1dd 100644 --- a/.claude/docs/technical/BLS_FOOD_REFERENCE.md +++ b/.claude/docs/technical/BLS_FOOD_REFERENCE.md @@ -33,7 +33,7 @@ FDDB: Items persistieren; `nutrition_log` nur bei leerem Tag oder laut Policy / - Sichtbarkeit: `data_layer/food_visibility.py`. Lookup-Mapping: User-Zeile nur wenn Food sichtbar; globale Mapping-Zeile nur `owner_profile_id IS NULL`. `upsert_food_mapping` erzwingt dasselbe. Vorschläge sortieren persönlich → zentral → BLS. - `GET /admin/bls/foods?kind=manual` — `manual_user`/`manual_admin`. `POST /admin/bls/foods/manual` — zentrale Foods. `PUT /admin/bls/foods/manual/{id}` — zentral **und** persönlich (`attributes` + `serving_g`). `GET /admin/bls/foods/{id}?editor=true` — Editor-Payload. Migration **069**: Owner-Check (persönlich hat Owner, BLS/Admin nicht). - Migration **065** — Extension-Keys EPA/DHA/DPA/ALA/OMEGA3/OMEGA6 falls BLS sie nicht unter diesem Key hat -- `/api/admin/bls/*` — Import, Katalog, Attribute +- `/api/admin/bls/*` — Import, Katalog, Attribute. `PUT /admin/bls/attributes/{id}` ändert Name/Einheit (nicht `attr_key`). `POST /admin/bls/attributes/fill-labels` füllt leere Namen aus BLS-4.0-Glossar (`bls/attribute_labels.py`). Lebensmittel-Import schreibt fehlende Namen aus Spaltenköpfen. Makro-Rollup: `catalog_macros_for_item` liest nur ENERCC/PROT625/FAT/CHO → `nutrition_log`; `rebuild_daily_nutrients` summiert alle `num_per_100g` nach `nutrition_daily_nutrients`. - `/api/admin/food-mappings` — Admin-CRUD - `/api/nutrition/*` — Items, Unmapped, Bulk-Map, Marken, Konflikt-Resolve - `GET/POST /api/nutrition/recipes`, `PUT/DELETE /api/nutrition/recipes/{id}`, `POST …/import-fddb-lists`, `POST …/{id}/apply` diff --git a/CLAUDE.md b/CLAUDE.md index 96c1d7d..752d884 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,7 +125,7 @@ frontend/src/ - **Zuordnen-Performance:** Katalog-Index im Prozess (5 Min.), Vorschläge nur für sichtbare Zeilen (`POST /bls/foods/suggest-batch`), Suche mit Abort; nach Bestätigen kein Reload der ganzen Ernährungseite. Unmapped ohne Kreuzjoin Liste×Tagebuch; Listenzutaten-Datum aus `recipe.updated_at` oder letzter `recipe_id`-Nutzung. Listen-API `?brief=true`; FDDB-Listenimport rechnet Tageswerte im Hintergrund. Migration **068**. - **Zuordnen-Zeitraum:** Standard letzte 4 Wochen (`since_days`); ältere ungemappte Namen (z. B. Getreide nach Glutenverzicht) bleiben unter „Alle“. - **Katalogsuche:** Fettgehalt mitsuchen (`Joghurt 10%` / `9,5`); Dezimal-Komma bleibt erhalten. -- **Manuelle Foods:** Persönlich (`manual_user` + Owner, nur dieser Nutzer) oder zentral (`manual_admin`, alle Nutzer). Stoffe über EAV. Nutzer: Tab Ernährung → **Lebensmittel** (persönlich ändern; zentral nur Admin). Admin: Katalog → Manuell — Listen **Zentral** und **Persönlich**, beide änderbar. Mapping: Nutzer-Mapping darf sichtbare Foods; globales Mapping nur BLS/zentral. Offizielle BLS-Zeilen nicht editierbar. Öl/Supplement = ein Lebensmittel, keine Liste. +- **Manuelle Foods:** Persönlich (`manual_user` + Owner, nur dieser Nutzer) oder zentral (`manual_admin`, alle Nutzer). Stoffe über EAV. Nutzer: Tab Ernährung → **Lebensmittel** (persönlich ändern; zentral nur Admin). Admin: Katalog → Manuell — Listen **Zentral** und **Persönlich**, beide änderbar. Stoffe & Attribute: Bezeichnung editierbar, Schlüssel fest; Makros nur ENERCC/PROT625/FAT/CHO → `nutrition_log`. Mapping: Nutzer-Mapping darf sichtbare Foods; globales Mapping nur BLS/zentral. Offizielle BLS-Zeilen nicht editierbar. Öl/Supplement = ein Lebensmittel, keine Liste. - **Listen / Kombinationen (Mitai):** Tab **Listen** (anlegen, bearbeiten, FDDB-Import). Im Listendialog Zutaten direkt BLS-zuordnen (Status offen/zugeordnet); Suche zuerst Listen-Namen und eigene Foods, dann BLS. **Tandoor:** User-Einstellungen URL + Token, Verbindungstest (`/api/tandoor/*`). Gerichte bleiben in Tandoor; später Mapping + `cooked_yield_g`. Admin-Mappings: `PUT /admin/food-mappings/{id}`. - **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. diff --git a/backend/bls/attribute_labels.py b/backend/bls/attribute_labels.py new file mode 100644 index 0000000..efbec20 --- /dev/null +++ b/backend/bls/attribute_labels.py @@ -0,0 +1,258 @@ +"""Official BLS 4.0 component labels (MRI documentation) and placeholder repair. + +``attr_key`` stays the identity. These names only fill empty or key-equal ``name_de``. +""" +from __future__ import annotations + +from typing import Any + +# Keys that feed nutrition_log kcal / protein_g / fat_g / carbs_g +MACRO_KEYS = ("ENERCC", "PROT625", "FAT", "CHO") + +# Code → (name_de, unit, category) from BLS 4.0 Referenzhandbuch (Jan 2026) +_OFFICIAL: dict[str, tuple[str, str, str]] = { + "ENERCJ": ("Energie (Kilojoule)", "kJ", "energy"), + "ENERCC": ("Energie (Kilokalorien)", "kcal", "energy"), + "WATER": ("Wasser", "g", "macro"), + "PROT625": ("Protein (N×6,25)", "g", "macro"), + "FAT": ("Fett", "g", "macro"), + "CHO": ("Kohlenhydrate, verfügbar", "g", "macro"), + "FIBT": ("Ballaststoffe, gesamt", "g", "fiber"), + "ALC": ("Alkohol (Ethanol)", "g", "macro"), + "OA": ("Organische Säuren, gesamt", "g", "organic_acids"), + "ASH": ("Rohasche", "g", "macro"), + "POLYL": ("Zuckeralkohole, gesamt", "g", "polyols"), + "GLUS": ("Glucose", "g", "carbs"), + "FRUS": ("Fructose", "g", "carbs"), + "GALS": ("Galactose", "g", "carbs"), + "MNSAC": ("Monosaccharide, gesamt", "g", "carbs"), + "SUCS": ("Saccharose", "g", "carbs"), + "MALS": ("Maltose", "g", "carbs"), + "LACS": ("Lactose", "g", "carbs"), + "DISAC": ("Disaccharide, gesamt", "g", "carbs"), + "SUGAR": ("Zucker, gesamt", "g", "carbs"), + "OLSAC": ("Oligosaccharide, verfügbar", "g", "carbs"), + "STARCH": ("Stärke", "g", "carbs"), + "FIBLMW": ("Ballaststoffe, niedermolekular", "g", "fiber"), + "FIBHMW": ("Ballaststoffe, hochmolekular", "g", "fiber"), + "FIBINS": ("Ballaststoffe, wasserunlöslich", "g", "fiber"), + "FIBSOL": ("Ballaststoffe, wasserlöslich", "g", "fiber"), + "FIBHMWS": ("Ballaststoffe, hochmolekular, wasserlöslich", "g", "fiber"), + "FIBHMWI": ("Ballaststoffe, hochmolekular, wasserunlöslich", "g", "fiber"), + "FASAT": ("Fettsäuren, gesättigt, gesamt", "g", "fatty_acids"), + "F4:0": ("Buttersäure (C4:0)", "g", "fatty_acids"), + "F6:0": ("Capronsäure (C6:0)", "g", "fatty_acids"), + "F8:0": ("Caprylsäure (C8:0)", "g", "fatty_acids"), + "F10:0": ("Caprinsäure (C10:0)", "g", "fatty_acids"), + "F12:0": ("Laurinsäure (C12:0)", "g", "fatty_acids"), + "F14:0": ("Myristinsäure (C14:0)", "g", "fatty_acids"), + "F15:0": ("Pentadecylsäure (C15:0)", "g", "fatty_acids"), + "F16:0": ("Palmitinsäure (C16:0)", "g", "fatty_acids"), + "F17:0": ("Margarinsäure (C17:0)", "g", "fatty_acids"), + "F18:0": ("Stearinsäure (C18:0)", "g", "fatty_acids"), + "F20:0": ("Arachinsäure (C20:0)", "g", "fatty_acids"), + "F22:0": ("Behensäure (C22:0)", "g", "fatty_acids"), + "F24:0": ("Lignocerinsäure (C24:0)", "g", "fatty_acids"), + "FAMS": ("Fettsäuren, einfach ungesättigt, gesamt", "g", "fatty_acids"), + "F14:1CN5": ("Myristoleinsäure (C14:1 n-5 cis)", "g", "fatty_acids"), + "F16:1CN7": ("Palmitoleinsäure (C16:1 n-7 cis)", "g", "fatty_acids"), + "F18:1CN7": ("Vaccensäure (C18:1 n-7 cis)", "g", "fatty_acids"), + "F18:1CN9": ("Ölsäure (C18:1 n-9 cis)", "g", "fatty_acids"), + "F20:1CN9": ("Gondosäure (C20:1 n-9 cis)", "g", "fatty_acids"), + "F22:1CN9": ("Erucasäure (C22:1 n-9 cis)", "g", "fatty_acids"), + "FAPU": ("Fettsäuren, mehrfach ungesättigt, gesamt", "g", "fatty_acids"), + "FAPUN3": ("Omega-3-Fettsäuren, gesamt", "g", "fatty_acids"), + "FAPUN6": ("Omega-6-Fettsäuren, gesamt", "g", "fatty_acids"), + "F18:2CN6": ("Linolsäure (C18:2 n-6 cis)", "g", "fatty_acids"), + "F18:2C9T11": ("Konjugierte Linolsäure (C18:2 cis-9, trans-11)", "g", "fatty_acids"), + "F18:3CN3": ("Alpha-Linolensäure (C18:3 n-3)", "g", "fatty_acids"), + "F18:3CN6": ("Gamma-Linolensäure (C18:3 n-6)", "g", "fatty_acids"), + "F18:4CN3": ("Stearidonsäure (C18:4 n-3)", "g", "fatty_acids"), + "F20:2CN6": ("Eicosadiensäure (C20:2 n-6)", "g", "fatty_acids"), + "F20:3CN6": ("Dihomo-Gamma-Linolensäure (C20:3 n-6)", "g", "fatty_acids"), + "F20:4CN6": ("Arachidonsäure (C20:4 n-6)", "g", "fatty_acids"), + "F20:5CN3": ("Eicosapentaensäure EPA (C20:5 n-3)", "g", "fatty_acids"), + "F22:5CN3": ("Docosapentaensäure DPA (C22:5 n-3)", "g", "fatty_acids"), + "F22:6CN3": ("Docosahexaensäure DHA (C22:6 n-3)", "g", "fatty_acids"), + "FAX": ("Fettsäuren, sonstige", "g", "fatty_acids"), + "AAE9": ("Aminosäuren, unentbehrlich, gesamt", "g", "amino_acids"), + "ILE": ("Isoleucin", "g", "amino_acids"), + "LEU": ("Leucin", "g", "amino_acids"), + "LYS": ("Lysin", "g", "amino_acids"), + "MET": ("Methionin", "g", "amino_acids"), + "PHE": ("Phenylalanin", "g", "amino_acids"), + "THR": ("Threonin", "g", "amino_acids"), + "TRP": ("Tryptophan", "g", "amino_acids"), + "VAL": ("Valin", "g", "amino_acids"), + "HIS": ("Histidin", "g", "amino_acids"), + "ALA": ("Alanin", "g", "amino_acids"), + "ARG": ("Arginin", "g", "amino_acids"), + "ASP": ("Asparaginsäure inkl. Asparagin", "g", "amino_acids"), + "CYS": ("Cystein", "g", "amino_acids"), + "GLU": ("Glutaminsäure inkl. Glutamin", "g", "amino_acids"), + "GLY": ("Glycin", "g", "amino_acids"), + "PRO": ("Prolin", "g", "amino_acids"), + "SER": ("Serin", "g", "amino_acids"), + "TYR": ("Tyrosin", "g", "amino_acids"), + "VITA": ("Vitamin A, Retinol-Äquivalent (RE)", "µg", "vitamins"), + "VITAA": ("Vitamin A, Retinol-Aktivitätsäquivalent (RAE)", "µg", "vitamins"), + "RETOL": ("Retinol", "µg", "vitamins"), + "CARTB": ("Beta-Carotin", "µg", "vitamins"), + "CAROTPAXB": ("Carotinoide außer Beta-Carotin", "µg", "vitamins"), + "VITD": ("Vitamin D", "µg", "vitamins"), + "ERGCAL": ("Vitamin D2 (Ergocalciferol)", "µg", "vitamins"), + "CHOCAL": ("Vitamin D3 (Cholecalciferol)", "µg", "vitamins"), + "VITE": ("Vitamin E (Alpha-Tocopherol)", "mg", "vitamins"), + "TOCPHA": ("Alpha-Tocopherol", "mg", "vitamins"), + "TOCPHB": ("Beta-Tocopherol", "mg", "vitamins"), + "TOCPHG": ("Gamma-Tocopherol", "mg", "vitamins"), + "TOCPHD": ("Delta-Tocopherol", "mg", "vitamins"), + "TOCTRA": ("Alpha-Tocotrienol", "mg", "vitamins"), + "VITK": ("Vitamin K", "µg", "vitamins"), + "VITK1": ("Vitamin K1 (Phyllochinon)", "µg", "vitamins"), + "VITK2": ("Vitamin K2 (Menachinone)", "µg", "vitamins"), + "THIA": ("Vitamin B1 (Thiamin)", "mg", "vitamins"), + "RIBF": ("Vitamin B2 (Riboflavin)", "mg", "vitamins"), + "NIA": ("Niacin", "mg", "vitamins"), + "NIAEQ": ("Niacin-Äquivalent", "mg", "vitamins"), + "PANTAC": ("Pantothensäure", "mg", "vitamins"), + "VITB6": ("Vitamin B6", "µg", "vitamins"), + "BIOT": ("Biotin", "µg", "vitamins"), + "FOL": ("Folat-Äquivalent", "µg", "vitamins"), + "FOLFD": ("Folat", "µg", "vitamins"), + "FOLAC": ("Folsäure, synthetisch", "µg", "vitamins"), + "VITB12": ("Vitamin B12 (Cobalamine)", "µg", "vitamins"), + "VITC": ("Vitamin C", "mg", "vitamins"), + "NACL": ("Salz (Natriumchlorid)", "g", "minerals"), + "NA": ("Natrium", "mg", "minerals"), + "CLD": ("Chlorid", "mg", "minerals"), + "K": ("Kalium", "mg", "minerals"), + "CA": ("Calcium", "mg", "minerals"), + "MG": ("Magnesium", "mg", "minerals"), + "P": ("Phosphor", "mg", "minerals"), + "S": ("Schwefel", "mg", "minerals"), + "FE": ("Eisen", "mg", "minerals"), + "ZN": ("Zink", "mg", "minerals"), + "ID": ("Iodid", "µg", "minerals"), + "CU": ("Kupfer", "µg", "minerals"), + "MN": ("Mangan", "µg", "minerals"), + "FD": ("Fluorid", "µg", "minerals"), + "CR": ("Chrom", "µg", "minerals"), + "MO": ("Molybdän", "µg", "minerals"), + "ACEAC": ("Essigsäure", "g", "organic_acids"), + "CITAC": ("Zitronensäure", "g", "organic_acids"), + "LACAC": ("Milchsäure", "g", "organic_acids"), + "MALAC": ("Äpfelsäure", "g", "organic_acids"), + "TARAC": ("Weinsäure", "g", "organic_acids"), + "MANTL": ("Mannit", "g", "polyols"), + "SORTL": ("Sorbit", "g", "polyols"), + "XYLTL": ("Xylit", "g", "polyols"), + "CHORL": ("Cholesterin", "mg", "other"), + "NT": ("Stickstoff, gesamt", "g", "other"), + "EPA": ("Eicosapentaensäure (EPA)", "g", "fatty_acids"), + "DHA": ("Docosahexaensäure (DHA)", "g", "fatty_acids"), + "DPA": ("Docosapentaensäure (DPA)", "g", "fatty_acids"), + "ALA": ("Alpha-Linolensäure (ALA)", "g", "fatty_acids"), + "OMEGA3": ("Omega-3-Fettsäuren gesamt", "g", "fatty_acids"), + "OMEGA6": ("Omega-6-Fettsäuren gesamt", "g", "fatty_acids"), +} + + +def lookup_key(raw: str | None) -> str: + return str(raw or "").strip().upper() + + +def official_label(attr_key: str | None) -> dict[str, str] | None: + key = lookup_key(attr_key) + hit = _OFFICIAL.get(key) + if not hit: + hit = _OFFICIAL.get(key.replace("_", ":")) + if not hit: + return None + name_de, unit, category = hit + return {"name_de": name_de, "unit": unit, "category": category} + + +def label_is_placeholder(name_de: str | None, attr_key: str | None) -> bool: + name = (name_de or "").strip() + key = lookup_key(attr_key) + return (not name) or name.upper() == key + + +def apply_official_name(attr_key: str | None, name_de: str | None) -> str: + if not label_is_placeholder(name_de, attr_key): + return (name_de or "").strip() + lab = official_label(attr_key) + if lab: + return lab["name_de"] + return (name_de or "").strip() or lookup_key(attr_key) + + +def enrich_attributes_in_memory(attributes: list[dict[str, Any]]) -> list[dict[str, Any]]: + for a in attributes: + key = a.get("attr_key") + lab = official_label(key) + if label_is_placeholder(a.get("name_de"), key) and lab: + a["name_de"] = lab["name_de"] + a["unit"] = a.get("unit") or lab["unit"] + a["category"] = a.get("category") or lab["category"] + return attributes + + +def fill_placeholder_labels(cur) -> dict[str, int]: + cur.execute( + """ + SELECT id, attr_key, name_de, unit, category + FROM food_attributes + WHERE is_active = true + """ + ) + updated = 0 + for row in cur.fetchall(): + rec = dict(row) + lab = official_label(rec.get("attr_key")) + if not lab or not label_is_placeholder(rec.get("name_de"), rec.get("attr_key")): + continue + cur.execute( + """ + UPDATE food_attributes + SET name_de = %s, + unit = COALESCE(NULLIF(BTRIM(COALESCE(unit, '')), ''), %s), + category = COALESCE(NULLIF(BTRIM(COALESCE(category, '')), ''), %s), + updated_at = NOW() + WHERE id = %s + """, + (lab["name_de"], lab["unit"], lab["category"], rec["id"]), + ) + updated += 1 + return {"updated": updated} + + +def enrich_labels_from_food_headers(cur, headers: list[dict[str, Any]] | None) -> int: + updated = 0 + for h in headers or []: + key = h.get("attr_key") + name = (h.get("name_de") or "").strip() + unit = (h.get("unit") or "").strip() or None + if not key or not name or label_is_placeholder(name, key): + lab = official_label(key) + if not lab: + continue + name = lab["name_de"] + unit = unit or lab["unit"] + cur.execute( + """ + UPDATE food_attributes + SET name_de = %s, + unit = COALESCE(%s, unit), + updated_at = NOW() + WHERE attr_key = %s + AND ( + name_de IS NULL OR BTRIM(name_de) = '' OR name_de = attr_key + ) + """, + (name, unit, key), + ) + if cur.rowcount: + updated += cur.rowcount + return updated diff --git a/backend/bls/import_service.py b/backend/bls/import_service.py index 064917a..4d408d1 100644 --- a/backend/bls/import_service.py +++ b/backend/bls/import_service.py @@ -16,6 +16,9 @@ def should_persist_value(val: dict[str, Any]) -> bool: def upsert_attributes(cur, attributes: list[dict[str, Any]]) -> dict[str, int]: + from bls.attribute_labels import enrich_attributes_in_memory + + attributes = enrich_attributes_in_memory(list(attributes)) inserted = updated = 0 for a in attributes: cur.execute( @@ -47,7 +50,15 @@ def upsert_attributes(cur, attributes: list[dict[str, Any]]) -> dict[str, int]: 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]: +def upsert_foods( + cur, + foods: list[dict[str, Any]], + bls_version: str = "4.0", + attribute_headers: list[dict[str, Any]] | None = None, +) -> dict[str, int]: + if attribute_headers: + from bls.attribute_labels import enrich_labels_from_food_headers + enrich_labels_from_food_headers(cur, attribute_headers) cur.execute("SELECT attr_key, id FROM food_attributes") attr_ids = {r["attr_key"]: r["id"] for r in cur.fetchall()} codes = [f["bls_code"] for f in foods if f.get("bls_code")] diff --git a/backend/bls/jobs.py b/backend/bls/jobs.py index 286a760..d4e62d6 100644 --- a/backend/bls/jobs.py +++ b/backend/bls/jobs.py @@ -146,10 +146,15 @@ def _run_apply(job_id: str) -> None: total = len(foods) inserted = updated = values_written = 0 _update(job_id, progress={"current": 0, "total": total}) + headers = parsed.get("attribute_headers") for i in range(0, total, FOOD_CHUNK): chunk = foods[i : i + FOOD_CHUNK] with get_db() as conn: - stats = upsert_foods(get_cursor(conn), chunk) + stats = upsert_foods( + get_cursor(conn), + chunk, + attribute_headers=headers if i == 0 else None, + ) inserted += stats.get("inserted", 0) updated += stats.get("updated", 0) values_written += stats.get("values_written", 0) diff --git a/backend/bls/parser.py b/backend/bls/parser.py index 63e90ee..82b8c56 100644 --- a/backend/bls/parser.py +++ b/backend/bls/parser.py @@ -26,25 +26,34 @@ def parse_components_xlsx(data: bytes) -> list[dict[str, Any]]: 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: + def col(*names: str, exclude: tuple[str, ...] = ()) -> int | None: for n in names: if n.lower() in idx: return idx[n.lower()] for key, i in idx.items(): + if any(ex in key for ex in exclude): + continue 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_code = col( + "component code", "komponentencode", "attr_key", "schlüssel", "schluessel", + "code", "komponente", + ) + i_de = col( + "name_de", "bezeichnung_de", "bezeichnung deutsch", "name de", + "deutsch", "german", "bezeichnung", + exclude=("name_en", "bezeichnung_en", "english", "englisch", "name en"), + ) + i_en = col("name_en", "bezeichnung_en", "name en", "english", "englisch") 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 + + from bls.attribute_labels import apply_official_name out = [] sort_order = 0 @@ -52,9 +61,10 @@ def parse_components_xlsx(data: bytes) -> list[dict[str, Any]]: 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"): + if not code or code.lower() in ("code", "schlüssel", "schluessel", "component code"): continue - name_de = _cell(raw[i_de] if i_de is not None and i_de < len(raw) else "") or code + raw_de = _cell(raw[i_de] if i_de is not None and i_de < len(raw) else "") + name_de = apply_official_name(code, raw_de) 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 diff --git a/backend/routers/admin_bls.py b/backend/routers/admin_bls.py index 16e3457..e25dca6 100644 --- a/backend/routers/admin_bls.py +++ b/backend/routers/admin_bls.py @@ -28,6 +28,14 @@ class AttributeCreate(BaseModel): enum_values: Optional[list] = None +class AttributeUpdate(BaseModel): + name_de: Optional[str] = None + name_en: Optional[str] = None + unit: Optional[str] = None + category: Optional[str] = None + sort_order: Optional[int] = None + + class ManualFoodCreate(BaseModel): name_de: str name_en: Optional[str] = None @@ -292,6 +300,8 @@ def admin_update_manual_food(food_id: str, body: ManualFoodCreate, session: dict @router.get("/attributes") def admin_list_attributes(session: dict = Depends(require_admin)): + from bls.attribute_labels import MACRO_KEYS, label_is_placeholder + with get_db() as conn: cur = get_cursor(conn) cur.execute( @@ -302,7 +312,23 @@ def admin_list_attributes(session: dict = Depends(require_admin)): ORDER BY origin, sort_order, attr_key """ ) - return [r2d(r) for r in cur.fetchall()] + rows = [] + for r in cur.fetchall(): + row = r2d(r) + key = row.get("attr_key") + row["is_macro"] = key in MACRO_KEYS + row["needs_label"] = label_is_placeholder(row.get("name_de"), key) + rows.append(row) + return rows + + +@router.post("/attributes/fill-labels") +def admin_fill_attribute_labels(session: dict = Depends(require_admin)): + from bls.attribute_labels import fill_placeholder_labels + + with get_db() as conn: + cur = get_cursor(conn) + return fill_placeholder_labels(cur) @router.post("/attributes") @@ -332,6 +358,53 @@ def admin_create_attribute(body: AttributeCreate, session: dict = Depends(requir return r2d(cur.fetchone()) +@router.put("/attributes/{attr_id}") +def admin_update_attribute(attr_id: int, body: AttributeUpdate, session: dict = Depends(require_admin)): + fields = [] + params: list = [] + if body.name_de is not None: + name = body.name_de.strip() + if not name: + raise HTTPException(400, "Name fehlt") + fields.append("name_de = %s") + params.append(name) + if body.name_en is not None: + fields.append("name_en = %s") + params.append(body.name_en.strip() or None) + if body.unit is not None: + fields.append("unit = %s") + params.append(body.unit.strip() or None) + if body.category is not None: + fields.append("category = %s") + params.append(body.category.strip() or None) + if body.sort_order is not None: + fields.append("sort_order = %s") + params.append(body.sort_order) + if not fields: + raise HTTPException(400, "Keine Änderung") + fields.append("updated_at = NOW()") + params.append(attr_id) + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + f""" + UPDATE food_attributes + SET {', '.join(fields)} + WHERE id = %s AND is_active = true + RETURNING id, attr_key, name_de, name_en, unit, category, data_type, origin, sort_order + """, + params, + ) + row = cur.fetchone() + if not row: + raise HTTPException(404, "Stoff nicht gefunden") + from bls.attribute_labels import MACRO_KEYS, label_is_placeholder + out = r2d(row) + out["is_macro"] = out.get("attr_key") in MACRO_KEYS + out["needs_label"] = label_is_placeholder(out.get("name_de"), out.get("attr_key")) + return out + + def _write_manual_macros(cur, food_id: str, macros: dict | None) -> None: from data_layer.food_attributes import macros_and_attributes_to_values, write_numeric_attributes write_numeric_attributes(cur, food_id, macros_and_attributes_to_values(macros, None)) diff --git a/backend/tests/test_attribute_labels.py b/backend/tests/test_attribute_labels.py new file mode 100644 index 0000000..3207d1e --- /dev/null +++ b/backend/tests/test_attribute_labels.py @@ -0,0 +1,40 @@ +from bls.attribute_labels import ( + apply_official_name, + fill_placeholder_labels, + label_is_placeholder, + official_label, +) + + +def test_official_macro_labels(): + assert official_label("ENERCC")["name_de"].startswith("Energie") + assert official_label("cho")["name_de"].startswith("Kohlenhydrate") + assert label_is_placeholder("ENERCC", "ENERCC") + assert label_is_placeholder("", "FAT") + assert not label_is_placeholder("Fett", "FAT") + assert apply_official_name("FAT", "FAT") == "Fett" + assert apply_official_name("FAT", "Fett gesamt") == "Fett gesamt" + + +def test_fill_placeholder_labels_skips_custom_names(): + class _Cur: + def __init__(self): + self.rows = [ + {"id": 1, "attr_key": "FAT", "name_de": "FAT", "unit": None, "category": None}, + {"id": 2, "attr_key": "EPA", "name_de": "Mein EPA", "unit": "g", "category": None}, + ] + self.updated = [] + + def execute(self, sql, params=None): + self.last = (sql, params) + if "UPDATE" in sql: + self.updated.append(params) + + def fetchall(self): + return self.rows + + cur = _Cur() + stats = fill_placeholder_labels(cur) + assert stats["updated"] == 1 + assert cur.updated[0][0] == "Fett" + assert cur.updated[0][3] == 1 diff --git a/backend/tests/test_bls_parser.py b/backend/tests/test_bls_parser.py index d399c33..9d8ffe0 100644 --- a/backend/tests/test_bls_parser.py +++ b/backend/tests/test_bls_parser.py @@ -30,6 +30,18 @@ def test_parse_components_dynamic(): assert "NA" in keys +def test_parse_components_official_bezeichnung_and_empty_name(): + data = _xlsx([ + ["Component code", "Bezeichnung", "English", "Einheit"], + ["PROT625", "", "Protein", "g/100g"], + ["FAT", "Fett", "Fat", "g/100g"], + ]) + attrs = parse_components_xlsx(data) + by_key = {a["attr_key"]: a for a in attrs} + assert by_key["PROT625"]["name_de"] == "Protein (N×6,25)" + assert by_key["FAT"]["name_de"] == "Fett" + + def test_parse_foods_keeps_bls_code(): data = _xlsx([ ["BLS Code", "Name", "Food name", "ENERCC Energie [kcal/100g]", "ENERCC Herkunft", "ENERCC Referenz"], diff --git a/backend/version.py b/backend/version.py index 89c7f0e..43860fc 100644 --- a/backend/version.py +++ b/backend/version.py @@ -22,7 +22,7 @@ MODULE_VERSIONS = { "activity": "1.2.1", # Legacy CSV import: activity_entries feature enforcement "nutrition": "1.3.7", # Sichtbarkeit persönlich vs. zentral im Mapping "tandoor": "1.0.1", # Router-Import ohne future-annotations (Backend startet) - "bls": "1.0.11", # Zentrale Manuals: Admin-Editor inkl. Nutzerliste; Admin-Katalog getrennt + "bls": "1.0.12", # Stoff-Bezeichnungen editierbar; BLS-Glossar; Makro-Pfad dokumentiert "photos": "1.0.0", "insights": "1.3.0", diff --git a/frontend/src/pages/AdminFoodAttributesPage.jsx b/frontend/src/pages/AdminFoodAttributesPage.jsx index e57343e..0cc9b67 100644 --- a/frontend/src/pages/AdminFoodAttributesPage.jsx +++ b/frontend/src/pages/AdminFoodAttributesPage.jsx @@ -1,22 +1,105 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { api } from '../utils/api' +const FILTERS = [ + { id: 'all', label: 'Alle' }, + { id: 'macro', label: 'Makros' }, + { id: 'needs', label: 'Ohne Bezeichnung' }, + { id: 'official', label: 'BLS' }, + { id: 'extension', label: 'Erweiterung' }, +] + +function matches(row, term, filter) { + if (filter === 'macro' && !row.is_macro) return false + if (filter === 'needs' && !row.needs_label) return false + if (filter === 'official' && row.origin !== 'official_bls') return false + if (filter === 'extension' && row.origin === 'official_bls') return false + if (!term) return true + const hay = `${row.attr_key} ${row.name_de || ''} ${row.name_en || ''} ${row.unit || ''}`.toLowerCase() + return hay.includes(term) +} + export default function AdminFoodAttributesPage() { const [rows, setRows] = useState([]) const [form, setForm] = useState({ attr_key: '', name_de: '', unit: '', data_type: 'num_per_100g' }) + const [q, setQ] = useState('') + const [filter, setFilter] = useState('all') + const [editId, setEditId] = useState(null) + const [draft, setDraft] = useState({ name_de: '', unit: '', category: '' }) const [error, setError] = useState(null) + const [notice, setNotice] = useState(null) + const [busy, setBusy] = useState(false) const load = () => api.adminBlsAttributes().then(setRows).catch((e) => setError(e.message)) useEffect(() => { load() }, []) + const term = q.trim().toLowerCase() + const shown = useMemo(() => rows.filter((r) => matches(r, term, filter)), [rows, term, filter]) + const missing = rows.filter((r) => r.needs_label).length + const macros = rows.filter((r) => r.is_macro) + + const startEdit = (r) => { + setEditId(r.id) + setDraft({ name_de: r.name_de || '', unit: r.unit || '', category: r.category || '' }) + setError(null) + } + + const saveEdit = async (id) => { + setBusy(true) + try { + const updated = await api.adminUpdateBlsAttribute(id, draft) + setRows((list) => list.map((r) => (r.id === id ? { ...r, ...updated } : r))) + setEditId(null) + setNotice('Bezeichnung gespeichert. Der Schlüssel bleibt unverändert.') + } catch (e) { + setError(e.message) + } finally { + setBusy(false) + } + } + + const fillLabels = async () => { + setBusy(true) + setError(null) + try { + const res = await api.adminFillBlsAttributeLabels() + await load() + setNotice(`${res.updated ?? 0} Bezeichnungen aus dem BLS-4.0-Glossar ergänzt.`) + } catch (e) { + setError(e.message) + } finally { + setBusy(false) + } + } + return (
- Offizielle BLS-Codes kommen aus dem Import. Erweiterungen (Histamin, Gluten, …) hier anlegen — ohne Migration. +
+ Jeder Stoff hat einen festen Schlüssel (BLS/EuroFIR, z. B. ENERCC). + Die deutsche Bezeichnung ist nur Anzeige — sie darf hier geändert werden, der Schlüssel nicht.
+
+ Weg in die Makro-Haupttabelle: Am Lebensmittel liegen Werte je 100 g in
+ {' '}food_attribute_values. Beim Gegessen-Eintrag zählt die Menge in Gramm.
+ Nur vier Schlüssel werden nach nutrition_log geschrieben:
+ Energie ENERCC → kcal, Protein PROT625 → protein_g,
+ Fett FAT → fat_g, Kohlenhydrate CHO → carbs_g
+ (Wert × Menge / 100). Alle anderen Stoffe (EPA, Ballaststoffe, Vitamine, …) landen in
+ {' '}nutrition_daily_nutrients, nicht in den vier Makro-Spalten.
+ Fehlt einer der vier Werte am zugeordneten Lebensmittel, gilt die Zeile als ungemappt
+ und es bleiben die FDDB-Makros des Tages.
+
+ Makros in dieser Liste:{' '} + {macros.map((m) => `${m.attr_key} (${m.name_de || 'ohne Name'})`).join(' · ')} +
+ )} {error &&{error}
} -{notice}
} + +{shown.length} von {rows.length} Stoffen
+ + {shown.map((r) => ( +