feat: Stoff-Bezeichnungen editieren und BLS-Glossar fuellen
Kryptische Schluessel bleiben Identitaet; Namen/Einheit sind aenderbar. Nur ENERCC/PROT625/FAT/CHO schreiben die Makro-Haupttabelle. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
e452d9a8fe
commit
94ddf84262
|
|
@ -46,7 +46,7 @@ Gelernte Zuordnungen und Listen lassen sich als **JSON sichern** und auf einer a
|
||||||
|
|
||||||
## Manuelle Lebensmittel / Supplemente
|
## 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.
|
**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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- 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).
|
- `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
|
- 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/admin/food-mappings` — Admin-CRUD
|
||||||
- `/api/nutrition/*` — Items, Unmapped, Bulk-Map, Marken, Konflikt-Resolve
|
- `/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`
|
- `GET/POST /api/nutrition/recipes`, `PUT/DELETE /api/nutrition/recipes/{id}`, `POST …/import-fddb-lists`, `POST …/{id}/apply`
|
||||||
|
|
|
||||||
|
|
@ -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-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“.
|
- **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.
|
- **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}`.
|
- **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
|
- **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.
|
- **Doku:** `.claude/docs/functional/BLS_FOOD_REFERENCE.md`, `.claude/docs/technical/BLS_FOOD_REFERENCE.md`, `docs/issues/issue-bls-food-mapping.md`. Folge #75.
|
||||||
|
|
|
||||||
258
backend/bls/attribute_labels.py
Normal file
258
backend/bls/attribute_labels.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -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]:
|
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
|
inserted = updated = 0
|
||||||
for a in attributes:
|
for a in attributes:
|
||||||
cur.execute(
|
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)}
|
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")
|
cur.execute("SELECT attr_key, id FROM food_attributes")
|
||||||
attr_ids = {r["attr_key"]: r["id"] for r in cur.fetchall()}
|
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")]
|
codes = [f["bls_code"] for f in foods if f.get("bls_code")]
|
||||||
|
|
|
||||||
|
|
@ -146,10 +146,15 @@ def _run_apply(job_id: str) -> None:
|
||||||
total = len(foods)
|
total = len(foods)
|
||||||
inserted = updated = values_written = 0
|
inserted = updated = values_written = 0
|
||||||
_update(job_id, progress={"current": 0, "total": total})
|
_update(job_id, progress={"current": 0, "total": total})
|
||||||
|
headers = parsed.get("attribute_headers")
|
||||||
for i in range(0, total, FOOD_CHUNK):
|
for i in range(0, total, FOOD_CHUNK):
|
||||||
chunk = foods[i : i + FOOD_CHUNK]
|
chunk = foods[i : i + FOOD_CHUNK]
|
||||||
with get_db() as conn:
|
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)
|
inserted += stats.get("inserted", 0)
|
||||||
updated += stats.get("updated", 0)
|
updated += stats.get("updated", 0)
|
||||||
values_written += stats.get("values_written", 0)
|
values_written += stats.get("values_written", 0)
|
||||||
|
|
|
||||||
|
|
@ -26,25 +26,34 @@ def parse_components_xlsx(data: bytes) -> list[dict[str, Any]]:
|
||||||
header = [_cell(c) for c in next(rows)]
|
header = [_cell(c) for c in next(rows)]
|
||||||
idx = {h.lower(): i for i, h in enumerate(header) if h}
|
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:
|
for n in names:
|
||||||
if n.lower() in idx:
|
if n.lower() in idx:
|
||||||
return idx[n.lower()]
|
return idx[n.lower()]
|
||||||
for key, i in idx.items():
|
for key, i in idx.items():
|
||||||
|
if any(ex in key for ex in exclude):
|
||||||
|
continue
|
||||||
for n in names:
|
for n in names:
|
||||||
if n.lower() in key:
|
if n.lower() in key:
|
||||||
return i
|
return i
|
||||||
return None
|
return None
|
||||||
|
|
||||||
i_code = col("code", "schlüssel", "schluessel", "attr_key", "komponente")
|
i_code = col(
|
||||||
i_de = col("name_de", "deutsch", "bezeichnung_de", "name de")
|
"component code", "komponentencode", "attr_key", "schlüssel", "schluessel",
|
||||||
i_en = col("name_en", "english", "bezeichnung_en", "name en")
|
"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_unit = col("unit", "einheit")
|
||||||
i_cat = col("category", "kategorie", "gruppe")
|
i_cat = col("category", "kategorie", "gruppe")
|
||||||
if i_code is None:
|
if i_code is None:
|
||||||
i_code = 0
|
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 = []
|
out = []
|
||||||
sort_order = 0
|
sort_order = 0
|
||||||
|
|
@ -52,9 +61,10 @@ def parse_components_xlsx(data: bytes) -> list[dict[str, Any]]:
|
||||||
if not raw:
|
if not raw:
|
||||||
continue
|
continue
|
||||||
code = _cell(raw[i_code] if i_code < len(raw) else "")
|
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
|
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
|
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
|
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
|
category = _cell(raw[i_cat] if i_cat is not None and i_cat < len(raw) else "") or None
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,14 @@ class AttributeCreate(BaseModel):
|
||||||
enum_values: Optional[list] = None
|
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):
|
class ManualFoodCreate(BaseModel):
|
||||||
name_de: str
|
name_de: str
|
||||||
name_en: Optional[str] = None
|
name_en: Optional[str] = None
|
||||||
|
|
@ -292,6 +300,8 @@ def admin_update_manual_food(food_id: str, body: ManualFoodCreate, session: dict
|
||||||
|
|
||||||
@router.get("/attributes")
|
@router.get("/attributes")
|
||||||
def admin_list_attributes(session: dict = Depends(require_admin)):
|
def admin_list_attributes(session: dict = Depends(require_admin)):
|
||||||
|
from bls.attribute_labels import MACRO_KEYS, label_is_placeholder
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
|
|
@ -302,7 +312,23 @@ def admin_list_attributes(session: dict = Depends(require_admin)):
|
||||||
ORDER BY origin, sort_order, attr_key
|
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")
|
@router.post("/attributes")
|
||||||
|
|
@ -332,6 +358,53 @@ def admin_create_attribute(body: AttributeCreate, session: dict = Depends(requir
|
||||||
return r2d(cur.fetchone())
|
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:
|
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
|
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))
|
write_numeric_attributes(cur, food_id, macros_and_attributes_to_values(macros, None))
|
||||||
|
|
|
||||||
40
backend/tests/test_attribute_labels.py
Normal file
40
backend/tests/test_attribute_labels.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -30,6 +30,18 @@ def test_parse_components_dynamic():
|
||||||
assert "NA" in keys
|
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():
|
def test_parse_foods_keeps_bls_code():
|
||||||
data = _xlsx([
|
data = _xlsx([
|
||||||
["BLS Code", "Name", "Food name", "ENERCC Energie [kcal/100g]", "ENERCC Herkunft", "ENERCC Referenz"],
|
["BLS Code", "Name", "Food name", "ENERCC Energie [kcal/100g]", "ENERCC Herkunft", "ENERCC Referenz"],
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ MODULE_VERSIONS = {
|
||||||
"activity": "1.2.1", # Legacy CSV import: activity_entries feature enforcement
|
"activity": "1.2.1", # Legacy CSV import: activity_entries feature enforcement
|
||||||
"nutrition": "1.3.7", # Sichtbarkeit persönlich vs. zentral im Mapping
|
"nutrition": "1.3.7", # Sichtbarkeit persönlich vs. zentral im Mapping
|
||||||
"tandoor": "1.0.1", # Router-Import ohne future-annotations (Backend startet)
|
"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",
|
"photos": "1.0.0",
|
||||||
"insights": "1.3.0",
|
"insights": "1.3.0",
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,105 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { api } from '../utils/api'
|
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() {
|
export default function AdminFoodAttributesPage() {
|
||||||
const [rows, setRows] = useState([])
|
const [rows, setRows] = useState([])
|
||||||
const [form, setForm] = useState({ attr_key: '', name_de: '', unit: '', data_type: 'num_per_100g' })
|
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 [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))
|
const load = () => api.adminBlsAttributes().then(setRows).catch((e) => setError(e.message))
|
||||||
useEffect(() => { load() }, [])
|
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 (
|
return (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h1 className="page-title">Stoffe & Attribute</h1>
|
<h1 className="page-title">Stoffe & Attribute</h1>
|
||||||
<p style={{ fontSize: 13, color: 'var(--text2)' }}>
|
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.55, margin: '0 0 12px' }}>
|
||||||
Offizielle BLS-Codes kommen aus dem Import. Erweiterungen (Histamin, Gluten, …) hier anlegen — ohne Migration.
|
Jeder Stoff hat einen festen <strong>Schlüssel</strong> (BLS/EuroFIR, z. B. ENERCC).
|
||||||
|
Die deutsche Bezeichnung ist nur Anzeige — sie darf hier geändert werden, der Schlüssel nicht.
|
||||||
</p>
|
</p>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.55, margin: '0 0 12px' }}>
|
||||||
|
<strong>Weg in die Makro-Haupttabelle:</strong> Am Lebensmittel liegen Werte je 100 g in
|
||||||
|
{' '}<code>food_attribute_values</code>. Beim Gegessen-Eintrag zählt die Menge in Gramm.
|
||||||
|
Nur vier Schlüssel werden nach <code>nutrition_log</code> geschrieben:
|
||||||
|
Energie <code>ENERCC</code> → kcal, Protein <code>PROT625</code> → protein_g,
|
||||||
|
Fett <code>FAT</code> → fat_g, Kohlenhydrate <code>CHO</code> → carbs_g
|
||||||
|
(Wert × Menge / 100). Alle anderen Stoffe (EPA, Ballaststoffe, Vitamine, …) landen in
|
||||||
|
{' '}<code>nutrition_daily_nutrients</code>, 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.
|
||||||
|
</p>
|
||||||
|
{macros.length > 0 && (
|
||||||
|
<p style={{ fontSize: 13, margin: '0 0 12px' }}>
|
||||||
|
Makros in dieser Liste:{' '}
|
||||||
|
{macros.map((m) => `${m.attr_key} (${m.name_de || 'ohne Name'})`).join(' · ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{error && <p style={{ color: 'var(--danger)' }}>{error}</p>}
|
{error && <p style={{ color: 'var(--danger)' }}>{error}</p>}
|
||||||
<div className="form-row">
|
{notice && <p style={{ color: 'var(--accent-dark)', fontSize: 13 }}>{notice}</p>}
|
||||||
|
|
||||||
|
<div className="form-row" style={{ marginBottom: 12 }}>
|
||||||
<input className="form-input" placeholder="KEY" value={form.attr_key} onChange={(e) => setForm({ ...form, attr_key: e.target.value })} />
|
<input className="form-input" placeholder="KEY" value={form.attr_key} onChange={(e) => setForm({ ...form, attr_key: e.target.value })} />
|
||||||
<input className="form-input" placeholder="Name DE" value={form.name_de} onChange={(e) => setForm({ ...form, name_de: e.target.value })} />
|
<input className="form-input" placeholder="Name DE" value={form.name_de} onChange={(e) => setForm({ ...form, name_de: e.target.value })} />
|
||||||
<input className="form-input" placeholder="Einheit" value={form.unit} onChange={(e) => setForm({ ...form, unit: e.target.value })} />
|
<input className="form-input" placeholder="Einheit" value={form.unit} onChange={(e) => setForm({ ...form, unit: e.target.value })} />
|
||||||
|
|
@ -26,17 +109,80 @@ export default function AdminFoodAttributesPage() {
|
||||||
<option value="text">Text</option>
|
<option value="text">Text</option>
|
||||||
<option value="enum">Auswahl</option>
|
<option value="enum">Auswahl</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="button" className="btn btn-primary" onClick={async () => {
|
<button
|
||||||
try {
|
type="button"
|
||||||
await api.adminCreateBlsAttribute(form)
|
className="btn btn-primary"
|
||||||
setForm({ attr_key: '', name_de: '', unit: '', data_type: 'num_per_100g' })
|
disabled={busy}
|
||||||
load()
|
onClick={async () => {
|
||||||
} catch (e) { setError(e.message) }
|
try {
|
||||||
}}>Anlegen</button>
|
await api.adminCreateBlsAttribute(form)
|
||||||
|
setForm({ attr_key: '', name_de: '', unit: '', data_type: 'num_per_100g' })
|
||||||
|
load()
|
||||||
|
} catch (e) { setError(e.message) }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Anlegen
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{rows.map((r) => (
|
|
||||||
<div key={r.id} style={{ fontSize: 13, padding: '6px 0', borderTop: '1px solid var(--border)' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 10 }}>
|
||||||
<strong>{r.attr_key}</strong> · {r.name_de} · {r.data_type} · {r.origin}
|
{FILTERS.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.id}
|
||||||
|
type="button"
|
||||||
|
className={'btn ' + (filter === f.id ? 'btn-primary' : 'btn-secondary')}
|
||||||
|
style={{ fontSize: 12 }}
|
||||||
|
onClick={() => setFilter(f.id)}
|
||||||
|
>
|
||||||
|
{f.label}{f.id === 'needs' && missing ? ` (${missing})` : ''}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} disabled={busy || missing === 0} onClick={fillLabels}>
|
||||||
|
Leere Namen aus BLS-Glossar füllen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
style={{ width: '100%', textAlign: 'left', marginBottom: 10 }}
|
||||||
|
placeholder="Stoffe suchen (Name oder Schlüssel)…"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p style={{ fontSize: 13, margin: '0 0 8px' }}>{shown.length} von {rows.length} Stoffen</p>
|
||||||
|
|
||||||
|
{shown.map((r) => (
|
||||||
|
<div key={r.id} style={{ padding: '8px 0', borderTop: '1px solid var(--border)' }}>
|
||||||
|
{editId === r.id ? (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 6 }}>
|
||||||
|
Schlüssel {r.attr_key} · {r.origin}{r.is_macro ? ' · Makro → nutrition_log' : ''}
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<input className="form-input" value={draft.name_de} onChange={(e) => setDraft({ ...draft, name_de: e.target.value })} placeholder="Name DE" />
|
||||||
|
<input className="form-input" value={draft.unit} onChange={(e) => setDraft({ ...draft, unit: e.target.value })} placeholder="Einheit" />
|
||||||
|
<input className="form-input" value={draft.category} onChange={(e) => setDraft({ ...draft, category: e.target.value })} placeholder="Gruppe" />
|
||||||
|
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => saveEdit(r.id)}>Speichern</button>
|
||||||
|
<button type="button" className="btn btn-secondary" disabled={busy} onClick={() => setEditId(null)}>Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600 }}>
|
||||||
|
{r.needs_label ? r.attr_key : (r.name_de || r.attr_key)}
|
||||||
|
{r.is_macro ? <span style={{ marginLeft: 8, fontSize: 11, color: 'var(--accent-dark)' }}>Makro</span> : null}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
|
||||||
|
{r.attr_key}
|
||||||
|
{r.needs_label ? ' · Bezeichnung fehlt (nur Schlüssel)' : ''}
|
||||||
|
{r.unit ? ` · ${r.unit}` : ''}
|
||||||
|
{` · ${r.data_type}`}
|
||||||
|
{` · ${r.origin}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-primary" style={{ fontSize: 12 }} onClick={() => startEdit(r)}>Bearbeiten</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -311,6 +311,8 @@ export const api = {
|
||||||
adminUpdateManualFood: (id, d) => req(`/admin/bls/foods/manual/${encodeURIComponent(id)}`, jput(d)),
|
adminUpdateManualFood: (id, d) => req(`/admin/bls/foods/manual/${encodeURIComponent(id)}`, jput(d)),
|
||||||
adminBlsAttributes: () => req('/admin/bls/attributes'),
|
adminBlsAttributes: () => req('/admin/bls/attributes'),
|
||||||
adminCreateBlsAttribute: (d) => req('/admin/bls/attributes', json(d)),
|
adminCreateBlsAttribute: (d) => req('/admin/bls/attributes', json(d)),
|
||||||
|
adminUpdateBlsAttribute: (id, d) => req(`/admin/bls/attributes/${id}`, jput(d)),
|
||||||
|
adminFillBlsAttributeLabels: () => req('/admin/bls/attributes/fill-labels', { method: 'POST' }),
|
||||||
adminListFoodMappings: (globalOnly) => req(`/admin/food-mappings${globalOnly?'?global_only=true':''}`),
|
adminListFoodMappings: (globalOnly) => req(`/admin/food-mappings${globalOnly?'?global_only=true':''}`),
|
||||||
adminCreateFoodMapping: (d) => req('/admin/food-mappings', json(d)),
|
adminCreateFoodMapping: (d) => req('/admin/food-mappings', json(d)),
|
||||||
adminUpdateFoodMapping: (id, d) => req(`/admin/food-mappings/${id}`, jput(d)),
|
adminUpdateFoodMapping: (id, d) => req(`/admin/food-mappings/${id}`, jput(d)),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user