Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c1873a47b1 | |||
| 86f15e6957 | |||
| 4b1be3019d | |||
| a07a668de2 | |||
| 8e964509d7 | |||
| 7ccae33844 | |||
| 9bb89231d0 | |||
| c90dabc032 | |||
| 919c77fcf8 | |||
| 72d101415f | |||
| ae7d0ac1d9 | |||
| 79ec249bc3 |
|
|
@ -1,6 +1,6 @@
|
||||||
# Gitea Issues – Landkarte (Auswertung)
|
# 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
|
**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.
|
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 |
|
| 37 | Feature-Enforcement für Activity CSV-Import |
|
||||||
| 38 | Feature-Enforcement für Nutrition CSV-Import UI |
|
| 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
|
### Qualität / Sonstiges
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) |
|
| 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 |
|
| 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` |
|
| 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 |
|
| **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 |
|
| 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 |
|
| Mitgliedschaft / Features | `technical/MEMBERSHIP_SYSTEM.md`, `architecture/FEATURE_ENFORCEMENT.md` | `backend/auth.py`, Feature-Logging, Router mit Enforcement |
|
||||||
|
|
|
||||||
31
.claude/docs/functional/BLS_FOOD_REFERENCE.md
Normal file
31
.claude/docs/functional/BLS_FOOD_REFERENCE.md
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
## Zuordnung (UX)
|
||||||
|
|
||||||
|
Der Nutzer sucht im **Popup nach dem Namen** (Katalogtreffer zeigen den BLS-Code nur nachrangig). Codes selbst heraussuchen ist nicht vorgesehen. Fehlt ein Treffer, kann ein **eigener Katalogeintrag** (Name + Makros/100 g) angelegt und sofort zugeordnet werden. Nicht-Gramm-Einheiten (Stück, EL, TL, …) bekommen ein **Gramm-pro-Einheit**-Feld am Mapping.
|
||||||
|
|
||||||
|
## FDDB-Listen / eigene Rezepte
|
||||||
|
|
||||||
|
FDDB-Tagebuchexport fasst selbst angelegte Listen oft zu **einer Zeile** (Rezeptname + Menge) zusammen. Die Zutaten stehen in einem **separaten Listen-Export** (`lists_*.csv`, Spalte `produkte`). Ablauf: Listen importieren → passende Tagebuchzeilen werden als Rezept verknüpft → **Zutaten** zuordnen, nicht das Rezept als Ganzes. Unvollständige Zutaten-Mappings fallen auf die FDDB-Makros der Tagebuchzeile zurück.
|
||||||
|
|
||||||
|
Gelernte Zuordnungen und Listen lassen sich als **JSON sichern** und auf einer anderen Instanz (Dev → Prod) wieder einspielen. Offizielle Lebensmittel werden über den BLS-Code gefunden — der BLS-Katalog muss auf dem Ziel bereits importiert sein.
|
||||||
|
|
||||||
|
## Fachliche Regeln
|
||||||
|
|
||||||
|
- BLS-Code (`bls_code`, Stoff-`attr_key`) bleibt die stabile Identität bei Reimports.
|
||||||
|
- 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).
|
||||||
|
|
@ -197,7 +197,7 @@ Phasen sind **sequentiell** wo „Abhängigkeit“ steht; Teile können parallel
|
||||||
|
|
||||||
**Erledigt:** Phase A — [`ACTIVITY_SCALAR_KANON_TABLE.md`](./ACTIVITY_SCALAR_KANON_TABLE.md).
|
**Erledigt:** Phase A — [`ACTIVITY_SCALAR_KANON_TABLE.md`](./ACTIVITY_SCALAR_KANON_TABLE.md).
|
||||||
|
|
||||||
**Aktuell:** Phase B fortsetzen (weitere Consumer prüfen: Export, Import-Vorschau, ggf. zukünftige Chart-Metriken aus EAV), dann **Phase C** (Schreibpfad), dann **Phase D** (Composite-MVP).
|
**Aktuell:** Phase B abgeschlossen (Consumer-Audit 2026-04-16). **Phase C** Schreibpfad entschlackt (Sync abgestellt, Orchestrator als SSoT; Review 2026-04-16 + Regression `test_activity_insert_sql.py`). Nächster Schritt: **Phase D** (Composite-MVP).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
40
.claude/docs/technical/BLS_FOOD_REFERENCE.md
Normal file
40
.claude/docs/technical/BLS_FOOD_REFERENCE.md
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# BLS Food Reference – technische Spec
|
||||||
|
|
||||||
|
**Stand:** 2026-09-12 · Migration **062** + **063**
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
- `food_recipes` / `food_recipe_ingredients` — FDDB-Listen; `nutrition_items.recipe_id`
|
||||||
|
|
||||||
|
## Layer 1
|
||||||
|
|
||||||
|
- `data_layer/food_mapping.py` — Normalisierung, Lookup, Learn, Apply, Delete
|
||||||
|
- `data_layer/nutrition_items.py` — Ingest, drei Makro-Summen, Policy, `resolve_*_attributes`
|
||||||
|
- `data_layer/food_recipes.py` — Listen-Upsert, Link auf Tagebuchzeilen, Rezept-Makros (Skala: gegessen_g / Summe Zutaten, sonst 1/Portionen)
|
||||||
|
- `bls/recipe_parser.py` — `produkte`-Feld: Split nur vor nächstem `\d+ (g|kg|ml|l)` (Kommas im Namen bleiben)
|
||||||
|
|
||||||
|
## 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
|
||||||
|
- `GET /api/nutrition/recipes`, `POST /api/nutrition/recipes/import-fddb-lists`, `POST /api/nutrition/recipes/{id}/apply`
|
||||||
|
- Unmapped = Tagebuchzeilen ohne `food_id`/`recipe_id` **plus** Rezeptzutaten ohne Mapping
|
||||||
|
- Frontend: `FoodSearchModal` (Name-Suche, eigener Eintrag, Gramm/Einheit), Listen-Import auf dem Tab Zuordnen
|
||||||
|
- Mapping-Schreiben und Nährwert-Rebuild sind getrennte Transaktionen (Rebuild darf das Mapping nicht zurückrollen)
|
||||||
|
- `food_name_mappings.grams_per_unit` / `source_unit` (Migration **064**)
|
||||||
|
- `GET/POST /api/nutrition/food-knowledge` — portable JSON (`mitai-food-knowledge` v1): manuelle Foods, Mappings (über `bls_code` / Name, keine UUIDs), Listen. Import löst Katalog auf dem Zielsystem auf (BLS muss dort importiert sein).
|
||||||
|
|
@ -46,10 +46,12 @@ Dieses Dokument ist **normativ für Agenten**, die ein neues Import-Zielmodul an
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Bekannte Einschränkungen (Follow-up in Gitea)
|
## 4. Validierung & Dry-Run (Stand 2026-07-23, Gitea #71)
|
||||||
|
|
||||||
- Admin **„Format prüfen“** kann `import_row_processing` derzeit weglassen; volle Parität mit dem gespeicherten Template erst beim Speichern / echten Import.
|
- Admin **„Format prüfen“** sendet dieselbe `import_row_processing`-Spec wie Speichern (`AdminCsvTemplateEditorPage` → `POST /api/admin/csv-templates/validate`).
|
||||||
- Nutzer-Mappings (Copy aus Systemvorlage) laufen nicht automatisch durch **`validate_csv_template`** – Tracking: **Gitea #71** (http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/71).
|
- **Profil-Mappings:** `POST /api/csv/mappings/{id}/copy` und `POST /api/csv/import` prüfen vor dem Schreiben mit **`validate_csv_template`** (HTTP 422 bei Fehlern).
|
||||||
|
- **Diagnose:** `GET /api/csv/mappings/{id}/validate` — Strukturprüfung ohne Import.
|
||||||
|
- **Nutzer-UI:** `UniversalCsvImportPage` zeigt `error_details` mit Zeile, `code` und `hint` (`CsvImportErrorDetails`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
10
.claude/docs/working/gitea-close-2026-07-23/comment-25.md
Normal file
10
.claude/docs/working/gitea-close-2026-07-23/comment-25.md
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
**Automatischer Audit (Code + Playwright, 2026-07-23)**
|
||||||
|
|
||||||
|
Ziele-System ist im Repo umgesetzt:
|
||||||
|
- Backend: `backend/routers/goals.py`, Focus Areas, Goal Types, Progress
|
||||||
|
- Frontend: `frontend/src/pages/GoalsPage.jsx`, Nav `/goals` in `config/appNav.js`
|
||||||
|
- Spec: `docs/issues/issue-50-phase-0a-goal-system.md`
|
||||||
|
|
||||||
|
Playwright auf dev.mitai.jinkendo.de: `/goals` rendert ohne Fehler.
|
||||||
|
|
||||||
|
Verbleibende KI-Goal-Erweiterungen ggf. als neues Issue.
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
**Automatischer Audit (Code, 2026-07-23)**
|
||||||
|
|
||||||
|
Deprecated Tabelle `subscriptions` ist aus dem aktiven Schema entfernt:
|
||||||
|
- Kein Treffer in `backend/schema.sql`, `backend/migrations/`, Backend-Python
|
||||||
|
- Membership nutzt `access_grants`, `tier_limits`, etc. (Migration v9c)
|
||||||
|
|
||||||
|
Doku-Hinweis deprecated: `.claude/docs/technical/DATABASE.md`
|
||||||
|
|
||||||
|
Optional Prod-Check: `\dt subscriptions` — sollte leer/nicht vorhanden sein.
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
**Automatischer Audit (Code, 2026-07-23)**
|
||||||
|
|
||||||
|
Bug behoben: JSONB-Insert für `abilities`/`profile` nutzt `psycopg2.extras.Json()`.
|
||||||
|
|
||||||
|
- Fix-Commit: `2977050` — wrap abilities dict with Json() for JSONB insert
|
||||||
|
- Aktuell: `backend/routers/admin_training_types.py` (`create_training_type`, Zeilen 111–112)
|
||||||
|
|
||||||
|
Admin-UI: `frontend/src/pages/AdminTrainingTypesPage.jsx`
|
||||||
23
.claude/docs/working/gitea-close-2026-07-23/comment-37.md
Normal file
23
.claude/docs/working/gitea-close-2026-07-23/comment-37.md
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
**Umsetzung 2026-07-23 (nach Architektur-Abgleich)**
|
||||||
|
|
||||||
|
## Architektur-Check vor Implementierung
|
||||||
|
|
||||||
|
- **Phase C (Schreibpfad):** laut `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` bereits erledigt (Sync abgestellt, Orchestrator als SSoT, keine Aufrufer von `sync_column_backed_session_metrics`). Keine Doppel-Implementierung.
|
||||||
|
- **Feature-ID:** Issue-Vorgabe `activity_entries` (nicht separates `activity_import`) — konsistent mit `create_activity` und Universal-CSV-Modul-Check in `csv_import.py`.
|
||||||
|
- **Legacy-Endpoint bleibt:** Frontend nutzt weiterhin `POST /api/activity/import-csv` (`ActivityPage` Apple-Health-Panel); Universal-Pfad ist parallel (ARCH §8.2).
|
||||||
|
|
||||||
|
## Änderungen
|
||||||
|
|
||||||
|
**Backend** (`routers/activity.py`):
|
||||||
|
- `check_feature_access(pid, "activity_entries")` vor Legacy-Import
|
||||||
|
- HTTP 403 bei Limit (wie `nutrition/import-csv`)
|
||||||
|
- `increment_feature_usage` pro neu eingefügter Zeile (`inserted`)
|
||||||
|
|
||||||
|
**Frontend** (`ActivityPage.jsx` ImportPanel):
|
||||||
|
- `UsageBadge` am Import-Titel
|
||||||
|
- Drop-Zone deaktiviert bei Limit
|
||||||
|
- Usage-Reload nach Import
|
||||||
|
|
||||||
|
**Tests:** `tests/test_activity_import_feature_enforcement.py` (403 + Increment)
|
||||||
|
|
||||||
|
Regression INSERT-SQL: `tests/test_activity_insert_sql.py` (grün)
|
||||||
22
.claude/docs/working/gitea-close-2026-07-23/comment-38.md
Normal file
22
.claude/docs/working/gitea-close-2026-07-23/comment-38.md
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
**Umsetzung 2026-07-23 (UI-Teil; Backend war bereits erledigt)**
|
||||||
|
|
||||||
|
## Ist-Zustand vorher
|
||||||
|
|
||||||
|
- Backend: `POST /api/nutrition/import-csv` mit `check_feature_access('nutrition_entries')` und HTTP 403 — **bereits implementiert**
|
||||||
|
- Frontend: FDDB-Import ohne Usage-Anzeige, Drop-Zone blieb bei Limit klickbar
|
||||||
|
|
||||||
|
## Änderungen
|
||||||
|
|
||||||
|
**Frontend** (`NutritionPage.jsx`, ImportPanel):
|
||||||
|
- `getFeatureUsage()` → `nutrition_entries`
|
||||||
|
- `UsageBadge` am Import-Titel
|
||||||
|
- Drop-Zone und Paste-Import deaktiviert bei Limit
|
||||||
|
- Usage-Reload nach erfolgreichem Import
|
||||||
|
|
||||||
|
Analog zu Activity #37 (geschlossen 2026-07-23).
|
||||||
|
|
||||||
|
**Playwright:** `tests/issue-audit.spec.js` — Badge am FDDB-Panel
|
||||||
|
|
||||||
|
## Hinweis
|
||||||
|
|
||||||
|
Universal-CSV (`/api/csv-import/import`) prüft zusätzlich `data_import` — Legacy FDDB-Pfad folgt dem Nutrition-Muster (nur `nutrition_entries`), konsistent mit Architektur §8.2.
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
**Automatischer Audit (Code + Playwright, 2026-07-23)**
|
||||||
|
|
||||||
|
Logout-Button im Mobile-Header neben Avatar implementiert:
|
||||||
|
- `frontend/src/App.jsx` — Button mit `title="Abmelden"`, Icon `LogOut`
|
||||||
|
- Zusätzlich Desktop: `frontend/src/components/DesktopSidebar.jsx`
|
||||||
|
|
||||||
|
Playwright auf dev.mitai.jinkendo.de: Button sichtbar und klickbar.
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
**Duplikat — geschlossen im Rahmen Issue-Audit 2026-07-23**
|
||||||
|
|
||||||
|
Inhalt identisch mit **#42** (Enhanced Debug/Prompt Analysis UI).
|
||||||
|
|
||||||
|
Teilumsetzung existiert bereits (`Analysis.jsx` Experten-Modus, `WorkflowDebugPanel`). Weiterverfolgung unter #42.
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
**Duplikat — geschlossen im Rahmen Issue-Audit 2026-07-23**
|
||||||
|
|
||||||
|
Inhalt identisch mit **#55** (Placeholder Registry: UNRESOLVED & TO_VERIFY Metadaten).
|
||||||
|
|
||||||
|
Bitte weiterverfolgen unter #55.
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
**Duplikat — geschlossen im Rahmen Issue-Audit 2026-07-23**
|
||||||
|
|
||||||
|
Inhalt identisch mit **#56** (Body Cluster — Restarbeiten & Metadaten-Verifizierung).
|
||||||
|
|
||||||
|
Bitte weiterverfolgen unter #56.
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
**Duplikat — geschlossen im Rahmen Issue-Audit 2026-07-23**
|
||||||
|
|
||||||
|
Inhalt identisch mit **#56** (Body Cluster — Restarbeiten & Metadaten-Verifizierung).
|
||||||
|
|
||||||
|
Bitte weiterverfolgen unter #56.
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
**Automatischer Audit (Code + Playwright, 2026-07-23)**
|
||||||
|
|
||||||
|
Nutzer-konfigurierbares Dashboard umgesetzt:
|
||||||
|
- Migration `039_dashboard_layout.sql` — `profiles.dashboard_layout`
|
||||||
|
- API: `backend/routers/app_dashboard.py` — GET/PUT/reset `/api/app/dashboard-layout`
|
||||||
|
- Frontend: `DashboardConfigurePage.jsx`, Widget-Registry `registerDashboardWidgets.js`
|
||||||
|
- Tests: `test_dashboard_layout_schema.py`, `test_widget_catalog.py`
|
||||||
|
|
||||||
|
Playwright: `/settings/dashboard-layout` erreichbar, Widget/Layout-UI sichtbar.
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
**Automatischer Audit (Code, 2026-07-23)**
|
||||||
|
|
||||||
|
Feature-Gate-Zuordnung aus Admin/DB implementiert:
|
||||||
|
- Migration `041_widget_feature_requirements.sql`
|
||||||
|
- Logik: `backend/widget_feature_requirements_db.py`, `dashboard_widget_entitlements.py`
|
||||||
|
- Admin-UI: `AdminWidgetFeatureAssignmentsPage.jsx`, Route `/admin/widget-features`
|
||||||
|
- Changelog: `backend/version.py` (Admin Widgets × Features)
|
||||||
|
|
||||||
|
Hardcodierter Katalog wird durch DB-Overrides ergänzt (Hybrid-Modell).
|
||||||
20
.claude/docs/working/gitea-close-2026-07-23/comment-71.md
Normal file
20
.claude/docs/working/gitea-close-2026-07-23/comment-71.md
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
**Implementierung abgeschlossen (2026-07-24)**
|
||||||
|
|
||||||
|
### 1. Admin „Format prüfen“ inkl. `import_row_processing`
|
||||||
|
- `AdminCsvTemplateEditorPage`: `resolveEditorImportRowProcessing()` — dieselbe Spec wie beim Speichern
|
||||||
|
- Dry-Run/Format-Check nutzt konsistente Vorlage inkl. Aggregation (`group_by` / `aggregates`)
|
||||||
|
|
||||||
|
### 2. Profil-Mappings validieren
|
||||||
|
- `csv_import.py`: `_validate_mapping_config` / `_ensure_mapping_valid` bei **Copy** und **Import**
|
||||||
|
- Neuer Endpoint: `GET /api/csv/mappings/{id}/validate`
|
||||||
|
- `api.js`: `validateCsvMapping()`; `formatFastApiDetail` für verschachtelte Validierungsfehler
|
||||||
|
|
||||||
|
### 3. Nutzer-UI: strukturierte Fehler
|
||||||
|
- Neue Komponente `CsvImportErrorDetails.jsx` (Zeile, code, hint)
|
||||||
|
- `UniversalCsvImportPage` zeigt `error_details` statt JSON-Dump
|
||||||
|
|
||||||
|
### Tests & Doku
|
||||||
|
- `backend/tests/test_csv_mapping_validation.py` — pytest grün
|
||||||
|
- Agent-Guide: `.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` §4 aktualisiert
|
||||||
|
|
||||||
|
**Betroffene Dateien:** `backend/routers/csv_import.py`, `frontend/src/pages/AdminCsvTemplateEditorPage.jsx`, `frontend/src/pages/UniversalCsvImportPage.jsx`, `frontend/src/components/CsvImportErrorDetails.jsx`, `frontend/src/utils/api.js`
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -58,6 +58,9 @@ coverage/
|
||||||
# Temp
|
# Temp
|
||||||
tmp/
|
tmp/
|
||||||
*.tmp
|
*.tmp
|
||||||
|
test-results/
|
||||||
|
screenshots/
|
||||||
|
tests/.auth/
|
||||||
|
|
||||||
# Claude: nur ausgewählte Bereiche versionieren (siehe .claude/rules/DOCUMENTATION.md)
|
# Claude: nur ausgewählte Bereiche versionieren (siehe .claude/rules/DOCUMENTATION.md)
|
||||||
.claude/**
|
.claude/**
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- **`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).
|
- **`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 mit Namenssuche (Popup); FDDB-Listen/Rezepte; JSON-Export/Import der Zuordnungen; Fasten/Lücke; Import-Abgleich.
|
||||||
|
- **Gitea #106:** BLS-Stammdaten, FDDB-Mapping, Item-Tagebuch — http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/106
|
||||||
|
- **Doku:** `.claude/docs/functional/BLS_FOOD_REFERENCE.md`, `.claude/docs/technical/BLS_FOOD_REFERENCE.md`, `docs/issues/issue-bls-food-mapping.md`. Folge #75.
|
||||||
|
|
||||||
### Updates (11.04.2026 - Gitea #75, nutrition_score Registry)
|
### 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
|
- **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
|
||||||
|
|
|
||||||
1
backend/bls/__init__.py
Normal file
1
backend/bls/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""BLS 4.0 ingest (official MRI XLSX)."""
|
||||||
140
backend/bls/import_service.py
Normal file
140
backend/bls/import_service.py
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
"""Apply parsed BLS 4.0 data: upsert attributes and foods, never delete official rows."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from psycopg2.extras import execute_values
|
||||||
|
|
||||||
|
VALUE_PAGE = 2000
|
||||||
|
FOOD_PAGE = 500
|
||||||
|
|
||||||
|
|
||||||
|
def should_persist_value(val: dict[str, Any]) -> bool:
|
||||||
|
if val.get("is_trace"):
|
||||||
|
return True
|
||||||
|
return val.get("value_num") is not None
|
||||||
|
|
||||||
|
|
||||||
|
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()}
|
||||||
|
codes = [f["bls_code"] for f in foods if f.get("bls_code")]
|
||||||
|
existing: set[str] = set()
|
||||||
|
if codes:
|
||||||
|
cur.execute("SELECT bls_code FROM food_catalog WHERE bls_code = ANY(%s)", (codes,))
|
||||||
|
existing = {r["bls_code"] for r in cur.fetchall()}
|
||||||
|
inserted = sum(1 for c in codes if c not in existing)
|
||||||
|
updated = len(codes) - inserted
|
||||||
|
food_rows = [
|
||||||
|
(
|
||||||
|
f["bls_code"], f["name_de"], f.get("name_en"), f.get("food_group"),
|
||||||
|
"official_bls", bls_version, "bls_4.0",
|
||||||
|
)
|
||||||
|
for f in foods if f.get("bls_code")
|
||||||
|
]
|
||||||
|
if food_rows:
|
||||||
|
execute_values(
|
||||||
|
cur,
|
||||||
|
"""
|
||||||
|
INSERT INTO food_catalog
|
||||||
|
(bls_code, name_de, name_en, food_group, catalog_kind, bls_version, source)
|
||||||
|
VALUES %s
|
||||||
|
ON CONFLICT (bls_code) DO UPDATE SET
|
||||||
|
name_de = EXCLUDED.name_de,
|
||||||
|
name_en = EXCLUDED.name_en,
|
||||||
|
food_group = EXCLUDED.food_group,
|
||||||
|
bls_version = EXCLUDED.bls_version,
|
||||||
|
catalog_kind = 'official_bls',
|
||||||
|
source = 'bls_4.0',
|
||||||
|
is_active = true,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE food_catalog.catalog_kind = 'official_bls'
|
||||||
|
""",
|
||||||
|
food_rows,
|
||||||
|
page_size=FOOD_PAGE,
|
||||||
|
)
|
||||||
|
|
||||||
|
food_ids: dict[str, Any] = {}
|
||||||
|
if codes:
|
||||||
|
cur.execute("SELECT bls_code, id FROM food_catalog WHERE bls_code = ANY(%s)", (codes,))
|
||||||
|
food_ids = {r["bls_code"]: r["id"] for r in cur.fetchall()}
|
||||||
|
|
||||||
|
value_rows = []
|
||||||
|
for f in foods:
|
||||||
|
food_id = food_ids.get(f.get("bls_code"))
|
||||||
|
if not food_id:
|
||||||
|
continue
|
||||||
|
for val in f.get("values") or []:
|
||||||
|
if not should_persist_value(val):
|
||||||
|
continue
|
||||||
|
aid = attr_ids.get(val["attr_key"])
|
||||||
|
if not aid:
|
||||||
|
continue
|
||||||
|
value_rows.append((
|
||||||
|
food_id,
|
||||||
|
aid,
|
||||||
|
None if val.get("is_trace") else val.get("value_num"),
|
||||||
|
bool(val.get("is_trace")),
|
||||||
|
val.get("origin_code"),
|
||||||
|
val.get("reference_text"),
|
||||||
|
))
|
||||||
|
|
||||||
|
if value_rows:
|
||||||
|
execute_values(
|
||||||
|
cur,
|
||||||
|
"""
|
||||||
|
INSERT INTO food_attribute_values
|
||||||
|
(food_id, attribute_id, value_num, is_trace, origin_code, reference_text, updated_at)
|
||||||
|
VALUES %s
|
||||||
|
ON CONFLICT (food_id, attribute_id) DO UPDATE SET
|
||||||
|
value_num = EXCLUDED.value_num,
|
||||||
|
is_trace = EXCLUDED.is_trace,
|
||||||
|
origin_code = EXCLUDED.origin_code,
|
||||||
|
reference_text = EXCLUDED.reference_text,
|
||||||
|
updated_at = NOW()
|
||||||
|
""",
|
||||||
|
value_rows,
|
||||||
|
template="(%s, %s, %s, %s, %s, %s, NOW())",
|
||||||
|
page_size=VALUE_PAGE,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"inserted": inserted,
|
||||||
|
"updated": updated,
|
||||||
|
"foods_inserted": inserted,
|
||||||
|
"foods_updated": updated,
|
||||||
|
"values_written": len(value_rows),
|
||||||
|
"foods_total": len(foods),
|
||||||
|
}
|
||||||
171
backend/bls/jobs.py
Normal file
171
backend/bls/jobs.py
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
"""In-memory BLS import jobs so HTTP requests stay short (avoid proxy 504)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
FOOD_CHUNK = 300
|
||||||
|
JOB_TTL_S = 2 * 60 * 60
|
||||||
|
MAX_JOBS = 12
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_jobs: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _public(job: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": job["id"],
|
||||||
|
"kind": job["kind"],
|
||||||
|
"status": job["status"],
|
||||||
|
"check": job.get("check"),
|
||||||
|
"progress": job.get("progress"),
|
||||||
|
"result": job.get("result"),
|
||||||
|
"error": job.get("error"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _purge_locked(now: float) -> None:
|
||||||
|
stale = [jid for jid, job in _jobs.items() if now - job["created_at"] > JOB_TTL_S]
|
||||||
|
for jid in stale:
|
||||||
|
_jobs.pop(jid, None)
|
||||||
|
if len(_jobs) <= MAX_JOBS:
|
||||||
|
return
|
||||||
|
oldest = sorted(_jobs.values(), key=lambda j: j["created_at"])
|
||||||
|
for job in oldest[: max(0, len(_jobs) - MAX_JOBS)]:
|
||||||
|
if job["status"] in ("checking", "applying"):
|
||||||
|
continue
|
||||||
|
_jobs.pop(job["id"], None)
|
||||||
|
|
||||||
|
|
||||||
|
def get_job(job_id: str) -> dict[str, Any] | None:
|
||||||
|
with _lock:
|
||||||
|
job = _jobs.get(job_id)
|
||||||
|
return _public(job) if job else None
|
||||||
|
|
||||||
|
|
||||||
|
def create_and_check(kind: str, raw: bytes) -> str:
|
||||||
|
if kind not in ("components", "foods"):
|
||||||
|
raise ValueError("kind muss components oder foods sein")
|
||||||
|
job_id = str(uuid.uuid4())
|
||||||
|
now = time.time()
|
||||||
|
with _lock:
|
||||||
|
_purge_locked(now)
|
||||||
|
_jobs[job_id] = {
|
||||||
|
"id": job_id,
|
||||||
|
"kind": kind,
|
||||||
|
"status": "checking",
|
||||||
|
"created_at": now,
|
||||||
|
"raw": raw,
|
||||||
|
"parsed": None,
|
||||||
|
"check": None,
|
||||||
|
"progress": None,
|
||||||
|
"result": None,
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
threading.Thread(target=_run_check, args=(job_id,), daemon=True).start()
|
||||||
|
return job_id
|
||||||
|
|
||||||
|
|
||||||
|
def start_apply(job_id: str) -> None:
|
||||||
|
with _lock:
|
||||||
|
job = _jobs.get(job_id)
|
||||||
|
if job is None:
|
||||||
|
raise KeyError(job_id)
|
||||||
|
if job["status"] != "checked":
|
||||||
|
raise ValueError("Zuerst die Prüfung abwarten")
|
||||||
|
if job.get("parsed") is None:
|
||||||
|
raise ValueError("Geparste Datei nicht mehr vorhanden — Datei neu wählen")
|
||||||
|
job["status"] = "applying"
|
||||||
|
job["error"] = None
|
||||||
|
job["progress"] = {"current": 0, "total": 0}
|
||||||
|
threading.Thread(target=_run_apply, args=(job_id,), daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
def _update(job_id: str, **fields: Any) -> None:
|
||||||
|
with _lock:
|
||||||
|
job = _jobs.get(job_id)
|
||||||
|
if not job:
|
||||||
|
return
|
||||||
|
job.update(fields)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_check(job_id: str) -> None:
|
||||||
|
with _lock:
|
||||||
|
job = _jobs.get(job_id)
|
||||||
|
if not job:
|
||||||
|
return
|
||||||
|
kind = job["kind"]
|
||||||
|
raw = job["raw"]
|
||||||
|
try:
|
||||||
|
if kind == "components":
|
||||||
|
attrs = parse_components_xlsx(raw)
|
||||||
|
_update(
|
||||||
|
job_id,
|
||||||
|
parsed=attrs,
|
||||||
|
raw=None,
|
||||||
|
check={"attributes": len(attrs)},
|
||||||
|
status="checked",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
parsed = parse_foods_xlsx(raw)
|
||||||
|
_update(
|
||||||
|
job_id,
|
||||||
|
parsed=parsed,
|
||||||
|
raw=None,
|
||||||
|
check={
|
||||||
|
"foods": len(parsed["foods"]),
|
||||||
|
"attribute_columns": len(parsed["attribute_headers"]),
|
||||||
|
},
|
||||||
|
status="checked",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
_update(job_id, status="error", error=str(e), raw=None, parsed=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_apply(job_id: str) -> None:
|
||||||
|
with _lock:
|
||||||
|
job = _jobs.get(job_id)
|
||||||
|
if not job:
|
||||||
|
return
|
||||||
|
kind = job["kind"]
|
||||||
|
parsed = job["parsed"]
|
||||||
|
try:
|
||||||
|
if kind == "components":
|
||||||
|
with get_db() as conn:
|
||||||
|
stats = upsert_attributes(get_cursor(conn), parsed)
|
||||||
|
_update(job_id, status="done", result=stats, parsed=None, progress={"current": stats.get("total", 0), "total": stats.get("total", 0)})
|
||||||
|
return
|
||||||
|
|
||||||
|
foods = parsed["foods"]
|
||||||
|
total = len(foods)
|
||||||
|
inserted = updated = values_written = 0
|
||||||
|
_update(job_id, progress={"current": 0, "total": total})
|
||||||
|
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)
|
||||||
|
inserted += stats.get("inserted", 0)
|
||||||
|
updated += stats.get("updated", 0)
|
||||||
|
values_written += stats.get("values_written", 0)
|
||||||
|
_update(job_id, progress={"current": min(i + FOOD_CHUNK, total), "total": total})
|
||||||
|
_update(
|
||||||
|
job_id,
|
||||||
|
status="done",
|
||||||
|
parsed=None,
|
||||||
|
result={
|
||||||
|
"inserted": inserted,
|
||||||
|
"updated": updated,
|
||||||
|
"foods_inserted": inserted,
|
||||||
|
"foods_updated": updated,
|
||||||
|
"values_written": values_written,
|
||||||
|
"foods_total": total,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
_update(job_id, status="error", error=str(e))
|
||||||
158
backend/bls/parser.py
Normal file
158
backend/bls/parser.py
Normal file
|
|
@ -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}
|
||||||
79
backend/bls/recipe_parser.py
Normal file
79
backend/bls/recipe_parser.py
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
"""Parse FDDB lists_*.csv (name;…;produkte) into recipe + ingredients."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from data_layer.food_mapping import normalize_food_name, parse_quantity_g
|
||||||
|
|
||||||
|
ING_RE = re.compile(
|
||||||
|
r"(?P<qty>\d+(?:[.,]\d+)?)\s*(?P<unit>g|kg|ml|l)\b\s*(?P<name>.+?)"
|
||||||
|
r"(?=,\s*\d+(?:[.,]\d+)?\s*(?:g|kg|ml|l)\b|$)",
|
||||||
|
re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_fddb_produkte(text: str) -> list[dict[str, Any]]:
|
||||||
|
raw = (text or "").strip().strip('"')
|
||||||
|
if not raw:
|
||||||
|
return []
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for i, m in enumerate(ING_RE.finditer(raw)):
|
||||||
|
qty = float(m.group("qty").replace(",", "."))
|
||||||
|
unit = m.group("unit").lower()
|
||||||
|
name = re.sub(r"\s+", " ", m.group("name")).strip(" ,;")
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
grams = qty
|
||||||
|
if unit == "kg":
|
||||||
|
grams = qty * 1000.0
|
||||||
|
elif unit == "l":
|
||||||
|
grams = qty * 1000.0
|
||||||
|
elif unit == "ml":
|
||||||
|
grams = qty
|
||||||
|
out.append({
|
||||||
|
"source_name_raw": name,
|
||||||
|
"source_name_normalized": normalize_food_name(name),
|
||||||
|
"quantity_raw": f"{m.group('qty').replace(',', '.')} {unit}",
|
||||||
|
"quantity_g": round(grams, 3),
|
||||||
|
"sort_order": i,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def parse_fddb_lists_csv(text: str) -> list[dict[str, Any]]:
|
||||||
|
if text.startswith("\ufeff"):
|
||||||
|
text = text[1:]
|
||||||
|
reader = csv.DictReader(io.StringIO(text), delimiter=";")
|
||||||
|
recipes = []
|
||||||
|
for row in reader:
|
||||||
|
name = (row.get("name") or "").strip().strip('"')
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
portions = float(str(row.get("anzahl_portionen") or "1").replace(",", "."))
|
||||||
|
except ValueError:
|
||||||
|
portions = 1.0
|
||||||
|
if portions <= 0:
|
||||||
|
portions = 1.0
|
||||||
|
ingredients = parse_fddb_produkte(row.get("produkte") or "")
|
||||||
|
if not ingredients:
|
||||||
|
leftover = (row.get("produkte") or "").strip().strip('"')
|
||||||
|
if leftover:
|
||||||
|
ingredients = [{
|
||||||
|
"source_name_raw": leftover,
|
||||||
|
"source_name_normalized": normalize_food_name(leftover),
|
||||||
|
"quantity_raw": None,
|
||||||
|
"quantity_g": parse_quantity_g(leftover),
|
||||||
|
"sort_order": 0,
|
||||||
|
}]
|
||||||
|
recipes.append({
|
||||||
|
"name_raw": name,
|
||||||
|
"name_normalized": normalize_food_name(name),
|
||||||
|
"portions": portions,
|
||||||
|
"description": (row.get("beschreibung") or "").strip() or None,
|
||||||
|
"ingredients": ingredients,
|
||||||
|
})
|
||||||
|
return recipes
|
||||||
|
|
@ -11,7 +11,31 @@ from typing import Any
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from csv_parser.core import iter_csv_dict_rows, resolve_effective_csv_delimiter
|
from csv_parser.core import iter_csv_dict_rows, normalize_header_for_signature, resolve_effective_csv_delimiter
|
||||||
|
|
||||||
|
_FOOD_NAME_HEADERS = frozenset({
|
||||||
|
"bezeichnung", "lebensmittel", "food", "food_name", "name", "gericht",
|
||||||
|
})
|
||||||
|
_QTY_HEADERS = frozenset({
|
||||||
|
"menge", "quantity", "quantity_raw", "portion", "amount", "gramm",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def guess_nutrition_item_fields(csv_row: dict[str, Any] | None) -> tuple[str | None, str | None]:
|
||||||
|
"""FDDB-Spalten auch ohne Vorlagen-Mapping auf food_name / menge erkennen."""
|
||||||
|
name = qty = None
|
||||||
|
for key, val in (csv_row or {}).items():
|
||||||
|
if val is None:
|
||||||
|
continue
|
||||||
|
text = str(val).strip().strip('"')
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
norm = normalize_header_for_signature(str(key))
|
||||||
|
if name is None and (norm in _FOOD_NAME_HEADERS or "bezeichnung" in norm):
|
||||||
|
name = text
|
||||||
|
if qty is None and (norm in _QTY_HEADERS or norm.startswith("menge")):
|
||||||
|
qty = text
|
||||||
|
return name, qty
|
||||||
from csv_parser.import_row_processing import (
|
from csv_parser.import_row_processing import (
|
||||||
aggregate_mapped_rows,
|
aggregate_mapped_rows,
|
||||||
resolve_import_row_processing,
|
resolve_import_row_processing,
|
||||||
|
|
@ -181,6 +205,8 @@ def run_universal_csv_import(
|
||||||
"error_details": error_details[:50],
|
"error_details": error_details[:50],
|
||||||
"new_entries": stats.get("new_entries", stats.get("inserted", 0)),
|
"new_entries": stats.get("new_entries", stats.get("inserted", 0)),
|
||||||
"affected_ids": dict(affected_ids),
|
"affected_ids": dict(affected_ids),
|
||||||
|
"items_written": stats.get("items_written", 0),
|
||||||
|
"unmapped_names": stats.get("unmapped_names", 0),
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
@ -203,6 +229,11 @@ def _import_nutrition(
|
||||||
for csv_row in iter_csv_dict_rows(text, delim, has_header=has_header):
|
for csv_row in iter_csv_dict_rows(text, delim, has_header=has_header):
|
||||||
rows_total += 1
|
rows_total += 1
|
||||||
mapped = build_row_after_mapping(csv_row, fm, tc, module="nutrition")
|
mapped = build_row_after_mapping(csv_row, fm, tc, module="nutrition")
|
||||||
|
guessed_name, guessed_qty = guess_nutrition_item_fields(csv_row)
|
||||||
|
if not mapped.get("food_name") and guessed_name:
|
||||||
|
mapped["food_name"] = guessed_name
|
||||||
|
if not mapped.get("quantity_raw") and guessed_qty:
|
||||||
|
mapped["quantity_raw"] = guessed_qty
|
||||||
d = coerce_date(mapped.get("date"))
|
d = coerce_date(mapped.get("date"))
|
||||||
if d is None:
|
if d is None:
|
||||||
error_details.append({"row": rows_total, "error": "Datum fehlt oder ungültig"})
|
error_details.append({"row": rows_total, "error": "Datum fehlt oder ungültig"})
|
||||||
|
|
@ -223,6 +254,50 @@ 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")
|
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)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(DISTINCT source_name_normalized) AS n
|
||||||
|
FROM nutrition_items
|
||||||
|
WHERE profile_id = %s AND food_id IS NULL
|
||||||
|
""",
|
||||||
|
(profile_id,),
|
||||||
|
)
|
||||||
|
unmapped_row = cur.fetchone() or {}
|
||||||
|
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),
|
||||||
|
"unmapped_names": int(unmapped_row.get("n") or 0),
|
||||||
|
"conflicts": ingest.get("conflicts") or [],
|
||||||
|
"policy": ingest.get("policy"),
|
||||||
|
}
|
||||||
|
|
||||||
inserted = 0
|
inserted = 0
|
||||||
updated = 0
|
updated = 0
|
||||||
new_entries = 0
|
new_entries = 0
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ _MODULE_HEADER_ALIASES: dict[str, dict[str, frozenset[str]]] = {
|
||||||
"protein_g": frozenset({"protein", "eiwei", "eiweiss"}),
|
"protein_g": frozenset({"protein", "eiwei", "eiweiss"}),
|
||||||
"fat_g": frozenset({"fett", "fat", "lipid"}),
|
"fat_g": frozenset({"fett", "fat", "lipid"}),
|
||||||
"carbs_g": frozenset({"kh", "carb", "kohlenhydr", "carbs", "sugar", "zucker"}),
|
"carbs_g": frozenset({"kh", "carb", "kohlenhydr", "carbs", "sugar", "zucker"}),
|
||||||
|
"food_name": frozenset({"bezeichnung", "lebensmittel", "food", "gericht"}),
|
||||||
|
"quantity_raw": frozenset({"menge", "quantity", "portion", "gramm"}),
|
||||||
},
|
},
|
||||||
"weight": {
|
"weight": {
|
||||||
"date": frozenset({"datum", "date", "tag", "day", "zeit"}),
|
"date": frozenset({"datum", "date", "tag", "day", "zeit"}),
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ MODULE_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
||||||
"protein_g": {"type": "float", "required": False, "min": 0, "unit": "g"},
|
"protein_g": {"type": "float", "required": False, "min": 0, "unit": "g"},
|
||||||
"fat_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"},
|
"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_key": ["profile_id", "date"],
|
||||||
"duplicate_strategy": "update",
|
"duplicate_strategy": "update",
|
||||||
|
|
|
||||||
253
backend/data_layer/food_knowledge.py
Normal file
253
backend/data_layer/food_knowledge.py
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
"""Portable JSON for user food mappings, manual foods, and FDDB lists."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from data_layer.food_mapping import (
|
||||||
|
apply_mapping_to_items,
|
||||||
|
apply_quantities_to_items,
|
||||||
|
normalize_food_name,
|
||||||
|
upsert_food_mapping,
|
||||||
|
)
|
||||||
|
from data_layer.food_recipes import list_recipes, upsert_recipes
|
||||||
|
from data_layer.nutrition_items import catalog_macros_for_item, dates_for_normalized_name, rebuild_daily_nutrients
|
||||||
|
|
||||||
|
BUNDLE_FORMAT = "mitai-food-knowledge"
|
||||||
|
BUNDLE_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
def _jsonable(value: Any) -> Any:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, Decimal):
|
||||||
|
return float(value)
|
||||||
|
if isinstance(value, UUID):
|
||||||
|
return str(value)
|
||||||
|
if hasattr(value, "isoformat"):
|
||||||
|
return value.isoformat()
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def parse_food_knowledge_bundle(data: Any) -> dict[str, Any]:
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError("Die Datei ist kein JSON-Objekt")
|
||||||
|
if data.get("format") != BUNDLE_FORMAT:
|
||||||
|
raise ValueError("Keine Mitai-Zuordnungsdatei — bitte die exportierte JSON verwenden")
|
||||||
|
try:
|
||||||
|
version = int(data.get("version") or 0)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError("Unbekannte Dateiversion") from exc
|
||||||
|
if version != BUNDLE_VERSION:
|
||||||
|
raise ValueError(f"Nicht unterstützte Dateiversion {version}")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def portable_mapping(row: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"source_system": row.get("source_system") or "fddb",
|
||||||
|
"source_name_raw": row.get("source_name_raw"),
|
||||||
|
"source_name_normalized": row.get("source_name_normalized"),
|
||||||
|
"bls_code": row.get("bls_code"),
|
||||||
|
"food_name_de": row.get("food_name_de") or row.get("name_de"),
|
||||||
|
"catalog_kind": row.get("catalog_kind"),
|
||||||
|
"external_key": row.get("external_key"),
|
||||||
|
"grams_per_unit": row.get("grams_per_unit"),
|
||||||
|
"source_unit": row.get("source_unit"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_catalog_food(cur, profile_id: str, ref: dict[str, Any]) -> str | None:
|
||||||
|
bls = (ref.get("bls_code") or "").strip()
|
||||||
|
if bls:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id FROM food_catalog WHERE bls_code = %s AND is_active = true",
|
||||||
|
(bls,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return str(row["id"]) if row else None
|
||||||
|
kind = ref.get("catalog_kind") or "manual_user"
|
||||||
|
name = (ref.get("food_name_de") or ref.get("name_de") or "").strip()
|
||||||
|
key = (ref.get("external_key") or "").strip()
|
||||||
|
if kind == "manual_user":
|
||||||
|
if key:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id FROM food_catalog
|
||||||
|
WHERE owner_profile_id = %s AND external_key = %s AND is_active = true
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(profile_id, key),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row:
|
||||||
|
return str(row["id"])
|
||||||
|
if name:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id FROM food_catalog
|
||||||
|
WHERE owner_profile_id = %s AND catalog_kind = 'manual_user'
|
||||||
|
AND lower(name_de) = lower(%s) AND is_active = true
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(profile_id, name),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row:
|
||||||
|
return str(row["id"])
|
||||||
|
return None
|
||||||
|
if kind == "manual_admin" and name:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id FROM food_catalog
|
||||||
|
WHERE catalog_kind = 'manual_admin' AND lower(name_de) = lower(%s)
|
||||||
|
AND is_active = true
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(name,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return str(row["id"]) if row else None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def export_food_knowledge(cur, profile_id: str) -> dict[str, Any]:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, name_de, name_en, catalog_kind, external_key
|
||||||
|
FROM food_catalog
|
||||||
|
WHERE owner_profile_id = %s AND catalog_kind = 'manual_user' AND is_active = true
|
||||||
|
ORDER BY lower(name_de)
|
||||||
|
""",
|
||||||
|
(profile_id,),
|
||||||
|
)
|
||||||
|
manuals = []
|
||||||
|
for food in cur.fetchall():
|
||||||
|
macros = catalog_macros_for_item(cur, food["id"], 100.0)
|
||||||
|
manuals.append({
|
||||||
|
"name_de": food["name_de"],
|
||||||
|
"name_en": food.get("name_en"),
|
||||||
|
"catalog_kind": food["catalog_kind"],
|
||||||
|
"external_key": food.get("external_key"),
|
||||||
|
"macros_per_100g": macros,
|
||||||
|
})
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT m.source_system, m.source_name_raw, m.source_name_normalized,
|
||||||
|
m.grams_per_unit, m.source_unit,
|
||||||
|
f.bls_code, f.name_de AS food_name_de, f.catalog_kind, f.external_key
|
||||||
|
FROM food_name_mappings m
|
||||||
|
JOIN food_catalog f ON f.id = m.food_id
|
||||||
|
WHERE m.profile_id = %s
|
||||||
|
ORDER BY m.source_name_normalized
|
||||||
|
""",
|
||||||
|
(profile_id,),
|
||||||
|
)
|
||||||
|
mappings = [portable_mapping(dict(r)) for r in cur.fetchall()]
|
||||||
|
recipes = []
|
||||||
|
for rec in list_recipes(cur, profile_id):
|
||||||
|
recipes.append({
|
||||||
|
"name_raw": rec.get("name_raw"),
|
||||||
|
"name_normalized": rec.get("name_normalized"),
|
||||||
|
"portions": _jsonable(rec.get("portions")),
|
||||||
|
"description": rec.get("description"),
|
||||||
|
"source": rec.get("source") or "fddb_list",
|
||||||
|
"ingredients": [
|
||||||
|
{
|
||||||
|
"source_name_raw": ing.get("source_name_raw"),
|
||||||
|
"source_name_normalized": ing.get("source_name_normalized"),
|
||||||
|
"quantity_raw": ing.get("quantity_raw"),
|
||||||
|
"quantity_g": _jsonable(ing.get("quantity_g")),
|
||||||
|
"sort_order": ing.get("sort_order") or 0,
|
||||||
|
}
|
||||||
|
for ing in rec.get("ingredients") or []
|
||||||
|
],
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"format": BUNDLE_FORMAT,
|
||||||
|
"version": BUNDLE_VERSION,
|
||||||
|
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"manual_foods": manuals,
|
||||||
|
"mappings": mappings,
|
||||||
|
"recipes": recipes,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_manual_food(cur, profile_id: str, food: dict[str, Any]) -> str | None:
|
||||||
|
name = (food.get("name_de") or "").strip()
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
existing = resolve_catalog_food(cur, profile_id, {**food, "food_name_de": name, "catalog_kind": "manual_user"})
|
||||||
|
if existing:
|
||||||
|
from routers.admin_bls import _write_manual_macros
|
||||||
|
_write_manual_macros(cur, existing, food.get("macros_per_100g"))
|
||||||
|
return existing
|
||||||
|
key = (food.get("external_key") or "").strip() or f"man-user-{normalize_food_name(name)[:40]}"
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO food_catalog
|
||||||
|
(name_de, name_en, catalog_kind, owner_profile_id, source, external_key)
|
||||||
|
VALUES (%s, %s, 'manual_user', %s, 'import', %s)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(name, food.get("name_en"), profile_id, key),
|
||||||
|
)
|
||||||
|
food_id = str(cur.fetchone()["id"])
|
||||||
|
from routers.admin_bls import _write_manual_macros
|
||||||
|
_write_manual_macros(cur, food_id, food.get("macros_per_100g"))
|
||||||
|
return food_id
|
||||||
|
|
||||||
|
|
||||||
|
def import_food_knowledge(cur, profile_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
bundle = parse_food_knowledge_bundle(data)
|
||||||
|
foods_upserted = 0
|
||||||
|
for food in bundle.get("manual_foods") or []:
|
||||||
|
if _upsert_manual_food(cur, profile_id, food):
|
||||||
|
foods_upserted += 1
|
||||||
|
mappings_ok = mappings_skipped = items_updated = 0
|
||||||
|
skipped: list[str] = []
|
||||||
|
dates: set[str] = set()
|
||||||
|
for raw in bundle.get("mappings") or []:
|
||||||
|
name = (raw.get("source_name_raw") or "").strip()
|
||||||
|
if not name:
|
||||||
|
mappings_skipped += 1
|
||||||
|
continue
|
||||||
|
food_id = resolve_catalog_food(cur, profile_id, raw)
|
||||||
|
if not food_id:
|
||||||
|
mappings_skipped += 1
|
||||||
|
label = raw.get("bls_code") or raw.get("food_name_de") or name
|
||||||
|
skipped.append(str(label))
|
||||||
|
continue
|
||||||
|
mid = upsert_food_mapping(
|
||||||
|
cur,
|
||||||
|
source_name_raw=name,
|
||||||
|
food_id=food_id,
|
||||||
|
profile_id=profile_id,
|
||||||
|
source="import",
|
||||||
|
source_system=raw.get("source_system") or "fddb",
|
||||||
|
grams_per_unit=raw.get("grams_per_unit"),
|
||||||
|
source_unit=raw.get("source_unit"),
|
||||||
|
)
|
||||||
|
norm = normalize_food_name(name)
|
||||||
|
items_updated += apply_mapping_to_items(cur, profile_id, norm, food_id, mid)
|
||||||
|
apply_quantities_to_items(cur, profile_id, norm, raw.get("grams_per_unit"))
|
||||||
|
dates.update(dates_for_normalized_name(cur, profile_id, norm))
|
||||||
|
mappings_ok += 1
|
||||||
|
recipe_stats = {"inserted": 0, "updated": 0, "ingredients": 0, "items_linked": 0}
|
||||||
|
recipes = bundle.get("recipes") or []
|
||||||
|
if recipes:
|
||||||
|
recipe_stats = upsert_recipes(cur, profile_id, recipes)
|
||||||
|
dates.update(recipe_stats.pop("dates_linked", []) or [])
|
||||||
|
for day in dates:
|
||||||
|
rebuild_daily_nutrients(cur, profile_id, day)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"manual_foods": foods_upserted,
|
||||||
|
"mappings": mappings_ok,
|
||||||
|
"mappings_skipped": mappings_skipped,
|
||||||
|
"items_updated": items_updated,
|
||||||
|
"skipped_foods": skipped[:20],
|
||||||
|
**recipe_stats,
|
||||||
|
}
|
||||||
362
backend/data_layer/food_mapping.py
Normal file
362
backend/data_layer/food_mapping.py
Normal file
|
|
@ -0,0 +1,362 @@
|
||||||
|
"""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 strip_leading_quantity(raw: str | None) -> str:
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
s = unicodedata.normalize("NFKC", str(raw)).strip().strip('"').strip("'")
|
||||||
|
s = s.lstrip("!")
|
||||||
|
s = LEADING_QTY_RE.sub("", s)
|
||||||
|
return MULTISPACE_RE.sub(" ", s).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_food_name(raw: str | None) -> str:
|
||||||
|
s = strip_leading_quantity(raw)
|
||||||
|
if not s:
|
||||||
|
return ""
|
||||||
|
s = DECIMAL_IN_NAME_RE.sub(r"\1.\2", s)
|
||||||
|
return s.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def merge_unmapped_rows(rows: list[dict]) -> list[dict]:
|
||||||
|
merged: dict[str, dict] = {}
|
||||||
|
for row in rows:
|
||||||
|
raw = row.get("source_name_raw") or ""
|
||||||
|
key = normalize_food_name(raw) or row.get("source_name_normalized") or raw.lower()
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
display = strip_leading_quantity(raw) or raw
|
||||||
|
count = int(row.get("count") or 0)
|
||||||
|
if key not in merged:
|
||||||
|
item = dict(row)
|
||||||
|
item["source_name_normalized"] = key
|
||||||
|
item["source_name_raw"] = display
|
||||||
|
item["count"] = count
|
||||||
|
item["variant_count"] = 1
|
||||||
|
merged[key] = item
|
||||||
|
continue
|
||||||
|
cur = merged[key]
|
||||||
|
cur["count"] = int(cur.get("count") or 0) + count
|
||||||
|
cur["variant_count"] = int(cur.get("variant_count") or 1) + 1
|
||||||
|
if display and (not cur.get("source_name_raw") or len(display) < len(cur["source_name_raw"])):
|
||||||
|
cur["source_name_raw"] = display
|
||||||
|
if row.get("matching_recipe_id") and not cur.get("matching_recipe_id"):
|
||||||
|
cur["matching_recipe_id"] = row["matching_recipe_id"]
|
||||||
|
if row.get("sample_quantity_raw") and not cur.get("sample_quantity_raw"):
|
||||||
|
cur["sample_quantity_raw"] = row["sample_quantity_raw"]
|
||||||
|
first, last = row.get("first_date"), row.get("last_date")
|
||||||
|
if first and (not cur.get("first_date") or str(first) < str(cur["first_date"])):
|
||||||
|
cur["first_date"] = first
|
||||||
|
if last and (not cur.get("last_date") or str(last) > str(cur["last_date"])):
|
||||||
|
cur["last_date"] = last
|
||||||
|
return list(merged.values())
|
||||||
|
|
||||||
|
|
||||||
|
UNIT_ALIASES = {
|
||||||
|
"g": "g", "gr": "g", "gramm": "g",
|
||||||
|
"kg": "kg",
|
||||||
|
"ml": "ml",
|
||||||
|
"l": "l", "liter": "l", "lt": "l",
|
||||||
|
"stück": "stück", "stk": "stück", "st": "stück", "st.": "stück", "pcs": "stück",
|
||||||
|
"el": "el", "esslöffel": "el",
|
||||||
|
"tl": "tl", "teelöffel": "tl",
|
||||||
|
"prise": "prise",
|
||||||
|
"scheibe": "scheibe",
|
||||||
|
"portion": "portion", "portionen": "portion",
|
||||||
|
"becher": "becher",
|
||||||
|
"tasse": "tasse",
|
||||||
|
"msp": "msp", "msp.": "msp",
|
||||||
|
}
|
||||||
|
MASS_VOLUME_TO_G = {"g": 1.0, "kg": 1000.0, "ml": 1.0, "l": 1000.0}
|
||||||
|
DEFAULT_UNIT_G = {"el": 15.0, "tl": 5.0, "prise": 0.3, "msp": 1.0}
|
||||||
|
COUNT_UNITS = frozenset({"stück", "scheibe", "portion", "becher", "tasse"})
|
||||||
|
QTY_PARSE_RE = re.compile(
|
||||||
|
r"^\s*(\d+(?:[.,]\d+)?)\s*([a-zA-ZäöüÄÖÜß.]+)?\s*$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _canon_unit(raw: str | None) -> str | None:
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
return UNIT_ALIASES.get(raw.strip().lower().rstrip("."))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_quantity(raw: str | None, grams_per_unit: float | None = None) -> dict[str, Any]:
|
||||||
|
empty = {"value": None, "unit": None, "quantity_g": None, "needs_unit_map": False}
|
||||||
|
if raw is None or str(raw).strip() == "":
|
||||||
|
return empty
|
||||||
|
text = str(raw).strip().replace(",", ".")
|
||||||
|
m = QTY_PARSE_RE.match(text)
|
||||||
|
if not m:
|
||||||
|
return empty
|
||||||
|
value = float(m.group(1))
|
||||||
|
unit = _canon_unit(m.group(2))
|
||||||
|
if unit is None:
|
||||||
|
return {"value": value, "unit": "g", "quantity_g": round(value, 3), "needs_unit_map": False}
|
||||||
|
if unit in MASS_VOLUME_TO_G:
|
||||||
|
return {
|
||||||
|
"value": value,
|
||||||
|
"unit": unit,
|
||||||
|
"quantity_g": round(value * MASS_VOLUME_TO_G[unit], 3),
|
||||||
|
"needs_unit_map": False,
|
||||||
|
}
|
||||||
|
factor = grams_per_unit if grams_per_unit is not None else DEFAULT_UNIT_G.get(unit)
|
||||||
|
if factor is not None:
|
||||||
|
return {
|
||||||
|
"value": value,
|
||||||
|
"unit": unit,
|
||||||
|
"quantity_g": round(value * float(factor), 3),
|
||||||
|
"needs_unit_map": unit in COUNT_UNITS,
|
||||||
|
}
|
||||||
|
return {"value": value, "unit": unit, "quantity_g": None, "needs_unit_map": True}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_quantity_g(raw: str | None, grams_per_unit: float | None = None) -> float | None:
|
||||||
|
return parse_quantity(raw, grams_per_unit)["quantity_g"]
|
||||||
|
|
||||||
|
|
||||||
|
def detect_quantity_unit(*texts: str | None) -> str | None:
|
||||||
|
for text in texts:
|
||||||
|
unit = parse_quantity(text).get("unit")
|
||||||
|
if unit and unit not in MASS_VOLUME_TO_G:
|
||||||
|
return unit
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
m.grams_per_unit, m.source_unit,
|
||||||
|
f.bls_code, f.name_de, f.catalog_kind
|
||||||
|
FROM food_name_mappings m
|
||||||
|
JOIN food_catalog f ON f.id = m.food_id
|
||||||
|
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,
|
||||||
|
m.grams_per_unit, m.source_unit,
|
||||||
|
f.bls_code, f.name_de, f.catalog_kind
|
||||||
|
FROM food_name_mappings m
|
||||||
|
JOIN food_catalog f ON f.id = m.food_id
|
||||||
|
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",
|
||||||
|
grams_per_unit: float | None = None,
|
||||||
|
source_unit: str | None = None,
|
||||||
|
) -> 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()
|
||||||
|
unit = _canon_unit(source_unit) if source_unit else None
|
||||||
|
if existing:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE food_name_mappings
|
||||||
|
SET food_id = %s, source_name_raw = %s, source = %s,
|
||||||
|
grams_per_unit = %s, source_unit = %s, updated_at = NOW()
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(food_id, raw, source, grams_per_unit, unit, existing["id"]),
|
||||||
|
)
|
||||||
|
return int(existing["id"])
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO food_name_mappings
|
||||||
|
(source_system, source_name_raw, source_name_normalized, food_id, profile_id,
|
||||||
|
source, grams_per_unit, source_unit, updated_at)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, NOW())
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(source_system, raw, norm, food_id, profile_id, source, grams_per_unit, unit),
|
||||||
|
)
|
||||||
|
return int(cur.fetchone()["id"])
|
||||||
|
|
||||||
|
|
||||||
|
def apply_quantities_to_items(cur, profile_id: str, source_name_normalized: str, grams_per_unit: float | None) -> int:
|
||||||
|
if not grams_per_unit:
|
||||||
|
return 0
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, quantity_raw, source_name_raw
|
||||||
|
FROM nutrition_items
|
||||||
|
WHERE profile_id = %s AND source_name_normalized = %s
|
||||||
|
""",
|
||||||
|
(profile_id, source_name_normalized),
|
||||||
|
)
|
||||||
|
n = 0
|
||||||
|
for row in cur.fetchall():
|
||||||
|
qty = parse_quantity_g(row.get("quantity_raw") or row.get("source_name_raw"), grams_per_unit)
|
||||||
|
if qty is None:
|
||||||
|
continue
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE nutrition_items SET quantity_g = %s, updated_at = NOW() WHERE id = %s",
|
||||||
|
(qty, row["id"]),
|
||||||
|
)
|
||||||
|
n += 1
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def apply_mapping_to_items(cur, profile_id: str, source_name_normalized: str, food_id: str, mapping_id: int) -> int:
|
||||||
|
origin = _value_origin_for_food(cur, food_id)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
AND recipe_id IS NULL
|
||||||
|
""",
|
||||||
|
(food_id, mapping_id, origin, profile_id, source_name_normalized),
|
||||||
|
)
|
||||||
|
n = cur.rowcount or 0
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, source_name_raw
|
||||||
|
FROM nutrition_items
|
||||||
|
WHERE profile_id = %s AND recipe_id IS NULL AND food_id IS NULL
|
||||||
|
""",
|
||||||
|
(profile_id,),
|
||||||
|
)
|
||||||
|
extra = [
|
||||||
|
row["id"] for row in cur.fetchall()
|
||||||
|
if normalize_food_name(row.get("source_name_raw")) == source_name_normalized
|
||||||
|
]
|
||||||
|
if extra:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE nutrition_items
|
||||||
|
SET food_id = %s, mapping_id = %s, value_origin = %s, updated_at = NOW()
|
||||||
|
WHERE id = ANY(%s)
|
||||||
|
""",
|
||||||
|
(food_id, mapping_id, origin, extra),
|
||||||
|
)
|
||||||
|
n += cur.rowcount or 0
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def clear_mapping_from_items(cur, profile_id: str, source_name_normalized: str) -> int:
|
||||||
|
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 []
|
||||||
|
primary = q.split(",")[0].strip() or q
|
||||||
|
like_full = f"%{q}%"
|
||||||
|
like_primary = f"%{primary}%"
|
||||||
|
prefix = f"{primary}%"
|
||||||
|
norm = normalize_food_name(primary)
|
||||||
|
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 name_de ILIKE %s
|
||||||
|
OR COALESCE(name_en, '') 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 name_de ILIKE %s THEN 1
|
||||||
|
WHEN COALESCE(bls_code, '') ILIKE %s THEN 2
|
||||||
|
ELSE 3 END,
|
||||||
|
name_de
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
profile_id,
|
||||||
|
like_full, like_primary, like_full, like_primary, like_full, norm,
|
||||||
|
norm, prefix, q, limit,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return [dict(r) for r in cur.fetchall()]
|
||||||
192
backend/data_layer/food_recipes.py
Normal file
192
backend/data_layer/food_recipes.py
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
"""FDDB recipe lists: upsert, link to diary items, catalog macros via ingredients."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from data_layer.food_mapping import get_food_mapping_with_cursor, normalize_food_name
|
||||||
|
from data_layer.nutrition_items import catalog_macros_for_item, _f
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_recipes(cur, profile_id: str, recipes: list[dict[str, Any]]) -> dict[str, int]:
|
||||||
|
inserted = updated = ingredients = 0
|
||||||
|
for rec in recipes:
|
||||||
|
norm = rec.get("name_normalized") or normalize_food_name(rec.get("name_raw"))
|
||||||
|
if not norm:
|
||||||
|
continue
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id FROM food_recipes WHERE profile_id = %s AND name_normalized = %s",
|
||||||
|
(profile_id, norm),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row:
|
||||||
|
rid = row["id"]
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE food_recipes
|
||||||
|
SET name_raw=%s, portions=%s, description=%s, updated_at=NOW()
|
||||||
|
WHERE id=%s
|
||||||
|
""",
|
||||||
|
(rec["name_raw"], rec.get("portions") or 1, rec.get("description"), rid),
|
||||||
|
)
|
||||||
|
cur.execute("DELETE FROM food_recipe_ingredients WHERE recipe_id = %s", (rid,))
|
||||||
|
updated += 1
|
||||||
|
else:
|
||||||
|
rid = str(uuid.uuid4())
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO food_recipes
|
||||||
|
(id, profile_id, name_raw, name_normalized, portions, description, source)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,'fddb_list')
|
||||||
|
""",
|
||||||
|
(rid, profile_id, rec["name_raw"], norm, rec.get("portions") or 1, rec.get("description")),
|
||||||
|
)
|
||||||
|
inserted += 1
|
||||||
|
for ing in rec.get("ingredients") or []:
|
||||||
|
inorm = ing.get("source_name_normalized") or normalize_food_name(ing.get("source_name_raw"))
|
||||||
|
if not inorm:
|
||||||
|
continue
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO food_recipe_ingredients
|
||||||
|
(id, recipe_id, source_name_raw, source_name_normalized,
|
||||||
|
quantity_raw, quantity_g, sort_order)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
str(uuid.uuid4()), rid, ing["source_name_raw"], inorm,
|
||||||
|
ing.get("quantity_raw"), ing.get("quantity_g"), ing.get("sort_order") or 0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
ingredients += 1
|
||||||
|
linked, dates = link_recipes_to_items(cur, profile_id)
|
||||||
|
return {
|
||||||
|
"inserted": inserted,
|
||||||
|
"updated": updated,
|
||||||
|
"ingredients": ingredients,
|
||||||
|
"items_linked": linked,
|
||||||
|
"dates_linked": dates,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def link_recipes_to_items(cur, profile_id: str) -> tuple[int, list[str]]:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT i.date::text AS date
|
||||||
|
FROM nutrition_items i
|
||||||
|
JOIN food_recipes r ON r.profile_id = i.profile_id
|
||||||
|
AND i.source_name_normalized = r.name_normalized
|
||||||
|
WHERE i.profile_id = %s AND i.food_id IS NULL
|
||||||
|
""",
|
||||||
|
(profile_id,),
|
||||||
|
)
|
||||||
|
dates = [r["date"] for r in cur.fetchall()]
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE nutrition_items i
|
||||||
|
SET recipe_id = r.id, updated_at = NOW()
|
||||||
|
FROM food_recipes r
|
||||||
|
WHERE i.profile_id = %s AND r.profile_id = %s
|
||||||
|
AND i.source_name_normalized = r.name_normalized
|
||||||
|
AND i.food_id IS NULL
|
||||||
|
""",
|
||||||
|
(profile_id, profile_id),
|
||||||
|
)
|
||||||
|
return cur.rowcount or 0, dates
|
||||||
|
|
||||||
|
|
||||||
|
def list_recipes(cur, profile_id: str) -> list[dict[str, Any]]:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, name_raw, name_normalized, portions, description, source
|
||||||
|
FROM food_recipes
|
||||||
|
WHERE profile_id = %s
|
||||||
|
ORDER BY name_normalized
|
||||||
|
""",
|
||||||
|
(profile_id,),
|
||||||
|
)
|
||||||
|
recipes = [dict(r) for r in cur.fetchall()]
|
||||||
|
if not recipes:
|
||||||
|
return []
|
||||||
|
ids = [r["id"] for r in recipes]
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT recipe_id, source_name_raw, source_name_normalized, quantity_raw, quantity_g, sort_order
|
||||||
|
FROM food_recipe_ingredients
|
||||||
|
WHERE recipe_id = ANY(%s)
|
||||||
|
ORDER BY sort_order, source_name_raw
|
||||||
|
""",
|
||||||
|
(ids,),
|
||||||
|
)
|
||||||
|
by_r: dict[str, list] = {str(i): [] for i in ids}
|
||||||
|
for row in cur.fetchall():
|
||||||
|
by_r.setdefault(str(row["recipe_id"]), []).append(dict(row))
|
||||||
|
for rec in recipes:
|
||||||
|
rec["ingredients"] = by_r.get(str(rec["id"]), [])
|
||||||
|
return recipes
|
||||||
|
|
||||||
|
|
||||||
|
def apply_recipe_to_items(cur, profile_id: str, source_name_normalized: str, recipe_id: str) -> int:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE nutrition_items
|
||||||
|
SET recipe_id = %s, food_id = NULL, mapping_id = NULL, value_origin = 'fddb', updated_at = NOW()
|
||||||
|
WHERE profile_id = %s AND source_name_normalized = %s
|
||||||
|
""",
|
||||||
|
(recipe_id, profile_id, source_name_normalized),
|
||||||
|
)
|
||||||
|
return cur.rowcount or 0
|
||||||
|
|
||||||
|
|
||||||
|
def mapped_ingredient_quantities(
|
||||||
|
cur, profile_id: str, recipe_id: str, eaten_qty_g: float | None
|
||||||
|
) -> list[dict[str, Any]] | None:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT portions FROM food_recipes WHERE id = %s AND profile_id = %s",
|
||||||
|
(recipe_id, profile_id),
|
||||||
|
)
|
||||||
|
rec = cur.fetchone()
|
||||||
|
if not rec:
|
||||||
|
return None
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT source_name_raw, quantity_g
|
||||||
|
FROM food_recipe_ingredients
|
||||||
|
WHERE recipe_id = %s
|
||||||
|
ORDER BY sort_order
|
||||||
|
""",
|
||||||
|
(recipe_id,),
|
||||||
|
)
|
||||||
|
ings = cur.fetchall()
|
||||||
|
if not ings:
|
||||||
|
return None
|
||||||
|
total_g = sum(_f(i.get("quantity_g")) for i in ings)
|
||||||
|
if eaten_qty_g and eaten_qty_g > 0 and total_g > 0:
|
||||||
|
scale = float(eaten_qty_g) / total_g
|
||||||
|
else:
|
||||||
|
portions = float(rec.get("portions") or 1) or 1.0
|
||||||
|
scale = 1.0 / portions
|
||||||
|
out = []
|
||||||
|
for ing in ings:
|
||||||
|
mapping = get_food_mapping_with_cursor(cur, ing["source_name_raw"], profile_id)
|
||||||
|
if not mapping:
|
||||||
|
return None
|
||||||
|
out.append({
|
||||||
|
"food_id": mapping["food_id"],
|
||||||
|
"quantity_g": _f(ing.get("quantity_g")) * scale,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def catalog_macros_for_recipe(cur, profile_id: str, recipe_id: str, eaten_qty_g: float | None) -> dict[str, float] | None:
|
||||||
|
parts = mapped_ingredient_quantities(cur, profile_id, recipe_id, eaten_qty_g)
|
||||||
|
if not parts:
|
||||||
|
return None
|
||||||
|
acc = {"kcal": 0.0, "protein_g": 0.0, "fat_g": 0.0, "carbs_g": 0.0}
|
||||||
|
for part in parts:
|
||||||
|
cat = catalog_macros_for_item(cur, part["food_id"], part["quantity_g"])
|
||||||
|
if not cat:
|
||||||
|
return None
|
||||||
|
for k in acc:
|
||||||
|
acc[k] += cat[k]
|
||||||
|
return acc
|
||||||
466
backend/data_layer/nutrition_items.py
Normal file
466
backend/data_layer/nutrition_items.py
Normal file
|
|
@ -0,0 +1,466 @@
|
||||||
|
"""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, recipe_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"))
|
||||||
|
if it.get("recipe_id") and not it.get("food_id"):
|
||||||
|
from data_layer.food_recipes import catalog_macros_for_recipe
|
||||||
|
cat = catalog_macros_for_recipe(cur, profile_id, it["recipe_id"], it.get("quantity_g"))
|
||||||
|
else:
|
||||||
|
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 _accumulate_food_qty(cur, acc: dict[int, list[float]], food_id: str, quantity_g: float) -> None:
|
||||||
|
if not food_id or not quantity_g or quantity_g <= 0:
|
||||||
|
return
|
||||||
|
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
|
||||||
|
""",
|
||||||
|
(food_id,),
|
||||||
|
)
|
||||||
|
factor = float(quantity_g) / 100.0
|
||||||
|
for row in cur.fetchall():
|
||||||
|
acc.setdefault(row["attribute_id"], []).append(float(row["value_num"]) * factor)
|
||||||
|
|
||||||
|
|
||||||
|
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 food_id, recipe_id, quantity_g
|
||||||
|
FROM nutrition_items
|
||||||
|
WHERE profile_id = %s AND date = %s
|
||||||
|
""",
|
||||||
|
(profile_id, day),
|
||||||
|
)
|
||||||
|
acc: dict[int, list[float]] = {}
|
||||||
|
from data_layer.food_recipes import mapped_ingredient_quantities
|
||||||
|
for it in cur.fetchall():
|
||||||
|
if it.get("food_id"):
|
||||||
|
_accumulate_food_qty(cur, acc, it["food_id"], _f(it.get("quantity_g")))
|
||||||
|
continue
|
||||||
|
if it.get("recipe_id"):
|
||||||
|
parts = mapped_ingredient_quantities(cur, profile_id, it["recipe_id"], it.get("quantity_g"))
|
||||||
|
if not parts:
|
||||||
|
continue
|
||||||
|
for part in parts:
|
||||||
|
_accumulate_food_qty(cur, acc, part["food_id"], part["quantity_g"])
|
||||||
|
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("grams_per_unit") if mapping else None,
|
||||||
|
)
|
||||||
|
mapping = get_food_mapping_with_cursor(cur, name, profile_id)
|
||||||
|
logged_at = raw.get("logged_at")
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
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, recipe_id
|
||||||
|
) VALUES (
|
||||||
|
%s,%s,%s,%s,%s,%s,'fddb',%s,%s,%s,%s,%s,%s,%s,%s,%s,'csv',%s
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
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),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
items_written += 1
|
||||||
|
from data_layer.food_recipes import link_recipes_to_items
|
||||||
|
link_recipes_to_items(cur, profile_id)
|
||||||
|
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
|
||||||
|
UNION
|
||||||
|
SELECT DISTINCT i.date::text AS date
|
||||||
|
FROM nutrition_items i
|
||||||
|
JOIN food_recipe_ingredients ri ON ri.recipe_id = i.recipe_id
|
||||||
|
WHERE i.profile_id = %s AND ri.source_name_normalized = %s
|
||||||
|
""",
|
||||||
|
(profile_id, source_name_normalized, profile_id, source_name_normalized),
|
||||||
|
)
|
||||||
|
return [r["date"] for r in cur.fetchall()]
|
||||||
|
|
@ -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 reports # Strukturierter PDF-Bericht (Profil v1)
|
||||||
from routers import csv_import, admin_csv_templates # Issue #21 Universal CSV Parser
|
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 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 ─────────────────────────────────────────────────────────
|
# ── App Configuration ─────────────────────────────────────────────────────────
|
||||||
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
|
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_csv_templates.router) # /api/admin/csv-templates/* (Issue #21)
|
||||||
app.include_router(admin_training_parameters.router) # /api/admin/training-parameters
|
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(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 ──────────────────────────────────────────────────────────────
|
# ── Health Check ──────────────────────────────────────────────────────────────
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|
|
||||||
205
backend/migrations/062_bls_food_catalog.sql
Normal file
205
backend/migrations/062_bls_food_catalog.sql
Normal file
|
|
@ -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 $$;
|
||||||
43
backend/migrations/063_fddb_recipes.sql
Normal file
43
backend/migrations/063_fddb_recipes.sql
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
-- Migration 063: FDDB-Listen/Rezepte + Verknüpfung an nutrition_items
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS food_recipes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
name_raw VARCHAR(500) NOT NULL,
|
||||||
|
name_normalized VARCHAR(500) NOT NULL,
|
||||||
|
portions NUMERIC(8,2) NOT NULL DEFAULT 1,
|
||||||
|
description TEXT,
|
||||||
|
source VARCHAR(20) NOT NULL DEFAULT 'fddb_list',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT uq_food_recipe_profile_name UNIQUE (profile_id, name_normalized)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_food_recipes_profile ON food_recipes (profile_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS food_recipe_ingredients (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
recipe_id UUID NOT NULL REFERENCES food_recipes(id) ON DELETE CASCADE,
|
||||||
|
source_name_raw VARCHAR(500) NOT NULL,
|
||||||
|
source_name_normalized VARCHAR(500) NOT NULL,
|
||||||
|
quantity_raw VARCHAR(80),
|
||||||
|
quantity_g NUMERIC(10,3),
|
||||||
|
sort_order INT NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_food_recipe_ing_recipe ON food_recipe_ingredients (recipe_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_food_recipe_ing_norm ON food_recipe_ingredients (source_name_normalized);
|
||||||
|
|
||||||
|
ALTER TABLE nutrition_items
|
||||||
|
ADD COLUMN IF NOT EXISTS recipe_id UUID REFERENCES food_recipes(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_nutrition_items_recipe
|
||||||
|
ON nutrition_items (recipe_id)
|
||||||
|
WHERE recipe_id IS NOT NULL;
|
||||||
|
|
||||||
|
COMMENT ON TABLE food_recipes IS 'FDDB-Listen/Rezepte; Tagebuchzeile kann statt Einzel-BLS auf ein Rezept zeigen';
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
RAISE NOTICE 'Migration 063: FDDB recipes + nutrition_items.recipe_id';
|
||||||
|
END $$;
|
||||||
18
backend/migrations/064_food_mapping_units.sql
Normal file
18
backend/migrations/064_food_mapping_units.sql
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
-- Migration 064: Mengeneinheiten am Mapping + source=import erlaubt
|
||||||
|
|
||||||
|
ALTER TABLE food_name_mappings
|
||||||
|
ADD COLUMN IF NOT EXISTS grams_per_unit NUMERIC(10,3),
|
||||||
|
ADD COLUMN IF NOT EXISTS source_unit VARCHAR(20);
|
||||||
|
|
||||||
|
ALTER TABLE food_name_mappings DROP CONSTRAINT IF EXISTS food_name_mappings_source_check;
|
||||||
|
|
||||||
|
ALTER TABLE food_name_mappings
|
||||||
|
ADD CONSTRAINT food_name_mappings_source_check
|
||||||
|
CHECK (source IN ('manual', 'bulk', 'admin', 'import'));
|
||||||
|
|
||||||
|
COMMENT ON COLUMN food_name_mappings.grams_per_unit IS 'Gramm pro Quell-Einheit (Stück, EL, …); NULL = Masse/Volumen bereits in g';
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
RAISE NOTICE 'Migration 064: mapping units + source import';
|
||||||
|
END $$;
|
||||||
|
|
@ -30,6 +30,7 @@ class ProfileUpdate(BaseModel):
|
||||||
goal_bf_pct: Optional[float] = None
|
goal_bf_pct: Optional[float] = None
|
||||||
quality_filter_level: Optional[str] = None # Issue #31: Global quality filter
|
quality_filter_level: Optional[str] = None # Issue #31: Global quality filter
|
||||||
email: Optional[str] = None # Self-service; leer = entfernen; Änderung setzt Verifikation zurück
|
email: Optional[str] = None # Self-service; leer = entfernen; Änderung setzt Verifikation zurück
|
||||||
|
nutrition_import_conflict_policy: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
# ── Tracking Models ───────────────────────────────────────────────────────────
|
# ── Tracking Models ───────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -12,3 +12,4 @@ python-dateutil==2.9.0
|
||||||
tzdata>=2024.1 # ZoneInfo (Europe/Berlin) auch unter Windows
|
tzdata>=2024.1 # ZoneInfo (Europe/Berlin) auch unter Windows
|
||||||
matplotlib==3.8.4
|
matplotlib==3.8.4
|
||||||
reportlab==4.2.0
|
reportlab==4.2.0
|
||||||
|
openpyxl==3.1.5
|
||||||
|
|
|
||||||
|
|
@ -617,6 +617,24 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional
|
||||||
Persistenz läuft über activity_persistence_orchestrator — gleiche Schicht wie Universal-CSV.
|
Persistenz läuft über activity_persistence_orchestrator — gleiche Schicht wie Universal-CSV.
|
||||||
"""
|
"""
|
||||||
pid = get_pid(x_profile_id)
|
pid = get_pid(x_profile_id)
|
||||||
|
|
||||||
|
# Feature-Enforcement (wie manueller Create + nutrition/import-csv): activity_entries
|
||||||
|
access = check_feature_access(pid, "activity_entries")
|
||||||
|
log_feature_usage(pid, "activity_entries", access, "import_csv")
|
||||||
|
if not access["allowed"]:
|
||||||
|
logger.warning(
|
||||||
|
f"[FEATURE-LIMIT] User {pid} blocked: "
|
||||||
|
f"activity_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})"
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail=(
|
||||||
|
f"Limit erreicht: Du hast das Kontingent für Aktivitätseinträge überschritten "
|
||||||
|
f"({access['used']}/{access['limit']}). "
|
||||||
|
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
raw = await file.read()
|
raw = await file.read()
|
||||||
try: text = raw.decode('utf-8')
|
try: text = raw.decode('utf-8')
|
||||||
except: text = raw.decode('latin-1')
|
except: text = raw.decode('latin-1')
|
||||||
|
|
@ -729,4 +747,8 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Import row failed: {e}")
|
logger.warning(f"Import row failed: {e}")
|
||||||
skipped+=1
|
skipped+=1
|
||||||
|
|
||||||
|
for _ in range(inserted):
|
||||||
|
increment_feature_usage(pid, "activity_entries")
|
||||||
|
|
||||||
return {"inserted":inserted,"skipped":skipped,"message":f"{inserted} Trainings importiert"}
|
return {"inserted":inserted,"skipped":skipped,"message":f"{inserted} Trainings importiert"}
|
||||||
|
|
|
||||||
282
backend/routers/admin_bls.py
Normal file
282
backend/routers/admin_bls.py
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
"""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 fastapi.concurrency import run_in_threadpool
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from auth import require_admin
|
||||||
|
from bls.import_service import upsert_attributes, upsert_foods
|
||||||
|
from bls.jobs import create_and_check, get_job, start_apply
|
||||||
|
from bls.parser import parse_components_xlsx, parse_foods_xlsx
|
||||||
|
from db import get_cursor, get_db, r2d
|
||||||
|
|
||||||
|
MAX_IMPORT_BYTES = 50 * 1024 * 1024
|
||||||
|
|
||||||
|
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 = await run_in_threadpool(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]}
|
||||||
|
|
||||||
|
def apply():
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
return upsert_attributes(cur, attrs)
|
||||||
|
|
||||||
|
stats = await run_in_threadpool(apply)
|
||||||
|
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 = await run_in_threadpool(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]
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def apply():
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
return upsert_foods(cur, foods)
|
||||||
|
|
||||||
|
stats = await run_in_threadpool(apply)
|
||||||
|
return {"dry_run": False, **stats}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import/jobs")
|
||||||
|
async def start_import_job(
|
||||||
|
kind: str,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
session: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
if kind not in ("components", "foods"):
|
||||||
|
raise HTTPException(400, "kind muss components oder foods sein")
|
||||||
|
raw = await file.read()
|
||||||
|
if not raw:
|
||||||
|
raise HTTPException(400, "Leere Datei")
|
||||||
|
if len(raw) > MAX_IMPORT_BYTES:
|
||||||
|
raise HTTPException(400, "Datei größer als 50 MB")
|
||||||
|
job_id = create_and_check(kind, raw)
|
||||||
|
return {"id": job_id, "status": "checking"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/import/jobs/{job_id}")
|
||||||
|
def import_job_status(job_id: str, session: dict = Depends(require_admin)):
|
||||||
|
job = get_job(job_id)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(404, "Import-Job nicht gefunden")
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import/jobs/{job_id}/apply")
|
||||||
|
def apply_import_job(job_id: str, session: dict = Depends(require_admin)):
|
||||||
|
try:
|
||||||
|
start_apply(job_id)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(404, "Import-Job nicht gefunden") from None
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(409, str(e)) from e
|
||||||
|
return get_job(job_id)
|
||||||
|
|
||||||
|
|
||||||
|
@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])),
|
||||||
|
)
|
||||||
103
backend/routers/admin_food_mappings.py
Normal file
103
backend/routers/admin_food_mappings.py
Normal file
|
|
@ -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}
|
||||||
188
backend/routers/bls.py
Normal file
188
backend/routers/bls.py
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
"""Authenticated catalog search and user-owned foods / mappings."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from auth import require_auth
|
||||||
|
from data_layer.food_mapping import (
|
||||||
|
apply_mapping_to_items,
|
||||||
|
apply_quantities_to_items,
|
||||||
|
clear_mapping_from_items,
|
||||||
|
normalize_food_name,
|
||||||
|
suggest_catalog_foods,
|
||||||
|
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
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
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"
|
||||||
|
grams_per_unit: Optional[float] = None
|
||||||
|
source_unit: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _pid(session: dict, x_profile_id: Optional[str] = None) -> str:
|
||||||
|
return x_profile_id or session["profile_id"]
|
||||||
|
|
||||||
|
|
||||||
|
def _rebuild_days(cur, profile_id: str, dates: list[str]) -> None:
|
||||||
|
for d in dates:
|
||||||
|
rebuild_daily_nutrients(cur, profile_id, d)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/foods")
|
||||||
|
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,
|
||||||
|
x_profile_id: Optional[str] = Header(default=None),
|
||||||
|
session: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
from routers.admin_bls import _write_manual_macros
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
pid = _pid(session, x_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)[:32]}-{uuid.uuid4().hex[:8]}"),
|
||||||
|
)
|
||||||
|
food = r2d(cur.fetchone())
|
||||||
|
_write_manual_macros(cur, food["id"], body.macros_per_100g)
|
||||||
|
return food
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mappings")
|
||||||
|
def list_my_mappings(
|
||||||
|
x_profile_id: Optional[str] = Header(default=None),
|
||||||
|
session: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
pid = _pid(session, x_profile_id)
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT m.id, m.source_name_raw, m.source_name_normalized, m.food_id, m.source,
|
||||||
|
m.grams_per_unit, m.source_unit,
|
||||||
|
f.name_de AS food_name_de, f.bls_code, f.catalog_kind
|
||||||
|
FROM food_name_mappings m
|
||||||
|
JOIN food_catalog f ON f.id = m.food_id
|
||||||
|
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,
|
||||||
|
x_profile_id: Optional[str] = Header(default=None),
|
||||||
|
session: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
pid = _pid(session, x_profile_id)
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT 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,
|
||||||
|
grams_per_unit=body.grams_per_unit,
|
||||||
|
source_unit=body.source_unit,
|
||||||
|
)
|
||||||
|
norm = normalize_food_name(body.source_name)
|
||||||
|
n = apply_mapping_to_items(cur, pid, norm, body.food_id, mid)
|
||||||
|
apply_quantities_to_items(cur, pid, norm, body.grams_per_unit)
|
||||||
|
dates = dates_for_normalized_name(cur, pid, norm)
|
||||||
|
try:
|
||||||
|
with get_db() as conn:
|
||||||
|
_rebuild_days(get_cursor(conn), pid, dates)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Nährwert-Rebuild nach Mapping %s fehlgeschlagen", norm)
|
||||||
|
return {"mapping_id": mid, "items_updated": n, "source_name_normalized": norm}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/mappings/{mapping_id}")
|
||||||
|
def delete_my_mapping(
|
||||||
|
mapping_id: int,
|
||||||
|
x_profile_id: Optional[str] = Header(default=None),
|
||||||
|
session: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
pid = _pid(session, x_profile_id)
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT 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))
|
||||||
|
try:
|
||||||
|
with get_db() as conn:
|
||||||
|
_rebuild_days(get_cursor(conn), pid, dates)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Nährwert-Rebuild nach Mapping-Löschen fehlgeschlagen")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# keep get_pid imported for consistency with other routers
|
||||||
|
_ = get_pid
|
||||||
|
|
@ -35,6 +35,7 @@ from csv_parser.type_converter import build_row_after_mapping, diagnose_row_mapp
|
||||||
from csv_parser.field_units import source_unit_choices_for_field
|
from csv_parser.field_units import source_unit_choices_for_field
|
||||||
from csv_parser.import_errors import enrich_row_error
|
from csv_parser.import_errors import enrich_row_error
|
||||||
from csv_parser.module_registry import get_module_definition, list_modules, validate_field_mappings
|
from csv_parser.module_registry import get_module_definition, list_modules, validate_field_mappings
|
||||||
|
from csv_parser.template_validator import validate_csv_template
|
||||||
from data_layer.activity_persistence_orchestrator import merge_activity_csv_module_fields
|
from data_layer.activity_persistence_orchestrator import merge_activity_csv_module_fields
|
||||||
from csv_parser.sleep_apple_import import detect_apple_sleep_csv_format
|
from csv_parser.sleep_apple_import import detect_apple_sleep_csv_format
|
||||||
|
|
||||||
|
|
@ -50,6 +51,38 @@ def _load_import_limits() -> dict[str, int]:
|
||||||
return get_csv_import_limits(r2d(row) if row else None)
|
return get_csv_import_limits(r2d(row) if row else None)
|
||||||
|
|
||||||
|
|
||||||
|
def _mapping_column_signature(m: dict) -> list[str] | None:
|
||||||
|
sig = m.get("column_signature")
|
||||||
|
if not sig:
|
||||||
|
return None
|
||||||
|
return list(sig)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_mapping_config(cur, m: dict) -> dict:
|
||||||
|
"""Strukturelle Vorlagen-Prüfung (wie Admin validate / Create)."""
|
||||||
|
return validate_csv_template(
|
||||||
|
m["module"],
|
||||||
|
m.get("field_mappings") or {},
|
||||||
|
m.get("type_conversions"),
|
||||||
|
m.get("import_row_processing"),
|
||||||
|
_mapping_column_signature(m),
|
||||||
|
cur=cur,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_mapping_valid(cur, m: dict) -> dict:
|
||||||
|
report = _validate_mapping_config(cur, m)
|
||||||
|
if not report.get("valid"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail={
|
||||||
|
"message": "CSV-Vorlage ist strukturell ungültig.",
|
||||||
|
"validation": report,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
def _mapping_to_summary(m: dict) -> dict:
|
def _mapping_to_summary(m: dict) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": m["id"],
|
"id": m["id"],
|
||||||
|
|
@ -184,6 +217,16 @@ def copy_csv_mapping(
|
||||||
n += 1
|
n += 1
|
||||||
name = f"{base_name} {n}"
|
name = f"{base_name} {n}"
|
||||||
|
|
||||||
|
validation = _validate_mapping_config(cur, src)
|
||||||
|
if not validation.get("valid"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail={
|
||||||
|
"message": "Quell-Vorlage ist ungültig — Kopie abgebrochen.",
|
||||||
|
"validation": validation,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO csv_field_mappings (
|
INSERT INTO csv_field_mappings (
|
||||||
|
|
@ -213,7 +256,21 @@ def copy_csv_mapping(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
new_id = cur.fetchone()["id"]
|
new_id = cur.fetchone()["id"]
|
||||||
return {"new_mapping_id": new_id, "mapping_name": name}
|
return {"new_mapping_id": new_id, "mapping_name": name, "validation": validation}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mappings/{mapping_id}/validate")
|
||||||
|
def validate_csv_mapping(
|
||||||
|
mapping_id: int,
|
||||||
|
session: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""Strukturprüfung einer gespeicherten Vorlage (System oder eigenes Profil-Mapping)."""
|
||||||
|
pid = str(session["profile_id"])
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
m = _fetch_mapping_row(cur, mapping_id, pid)
|
||||||
|
report = _validate_mapping_config(cur, m)
|
||||||
|
return {"mapping_id": mapping_id, "mapping_name": m.get("mapping_name"), **report}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/analyze")
|
@router.post("/analyze")
|
||||||
|
|
@ -538,6 +595,7 @@ async def csv_import_execute(
|
||||||
)
|
)
|
||||||
|
|
||||||
_check_module_feature_access(pid, exec_module)
|
_check_module_feature_access(pid, exec_module)
|
||||||
|
_ensure_mapping_valid(cur, m)
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
|
|
@ -654,6 +712,8 @@ async def csv_import_execute(
|
||||||
"updated": result["rows_updated"],
|
"updated": result["rows_updated"],
|
||||||
"skipped": result["rows_skipped"],
|
"skipped": result["rows_skipped"],
|
||||||
"errors": result["rows_errors"],
|
"errors": result["rows_errors"],
|
||||||
|
"items_written": result.get("items_written", 0),
|
||||||
|
"unmapped_names": result.get("unmapped_names", 0),
|
||||||
},
|
},
|
||||||
"error_details": result["error_details"],
|
"error_details": result["error_details"],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from typing import Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends
|
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends
|
||||||
|
from fastapi.responses import Response
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
from db import get_db, get_cursor, r2d
|
||||||
from auth import require_auth, check_feature_access, increment_feature_usage
|
from auth import require_auth, check_feature_access, increment_feature_usage
|
||||||
|
|
@ -31,8 +32,13 @@ def _pf(s):
|
||||||
|
|
||||||
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
||||||
@router.post("/import-csv")
|
@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)):
|
async def import_nutrition_csv(
|
||||||
"""Import FDDB 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)
|
pid = get_pid(x_profile_id)
|
||||||
|
|
||||||
# Phase 4: Check feature access and ENFORCE
|
# Phase 4: Check feature access and ENFORCE
|
||||||
|
|
@ -56,48 +62,97 @@ async def import_nutrition_csv(file: UploadFile=File(...), x_profile_id: Optiona
|
||||||
except: text = raw.decode('latin-1')
|
except: text = raw.decode('latin-1')
|
||||||
if text.startswith('\ufeff'): text = text[1:]
|
if text.startswith('\ufeff'): text = text[1:]
|
||||||
if not text.strip(): raise HTTPException(400,"Leere Datei")
|
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=';')
|
reader = csv.DictReader(io.StringIO(text), delimiter=';')
|
||||||
|
item_rows = []
|
||||||
days: dict = {}
|
days: dict = {}
|
||||||
count = 0
|
count = 0
|
||||||
for row in reader:
|
for row in reader:
|
||||||
rd = row.get('datum_tag_monat_jahr_stunde_minute','').strip().strip('"')
|
rd = row.get('datum_tag_monat_jahr_stunde_minute','').strip().strip('"')
|
||||||
if not rd: continue
|
if not rd: continue
|
||||||
try:
|
try:
|
||||||
p = rd.split(' ')[0].split('.')
|
parts = rd.split(' ')
|
||||||
|
p = parts[0].split('.')
|
||||||
iso = f"{p[2]}-{p[1]}-{p[0]}"
|
iso = f"{p[2]}-{p[1]}-{p[0]}"
|
||||||
except: continue
|
logged_at = None
|
||||||
days.setdefault(iso,{'kcal':0,'fat_g':0,'carbs_g':0,'protein_g':0})
|
if len(parts) > 1:
|
||||||
days[iso]['kcal'] += _pf(row.get('kj',0))/4.184
|
try:
|
||||||
days[iso]['fat_g'] += _pf(row.get('fett_g',0))
|
logged_at = datetime.strptime(rd.strip(), '%d.%m.%Y %H:%M')
|
||||||
days[iso]['carbs_g'] += _pf(row.get('kh_g',0))
|
except ValueError:
|
||||||
days[iso]['protein_g'] += _pf(row.get('protein_g',0))
|
logged_at = None
|
||||||
count+=1
|
except Exception:
|
||||||
inserted=0
|
continue
|
||||||
new_entries=0
|
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:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
for iso,vals in days.items():
|
policy = get_import_policy(cur, pid)
|
||||||
kcal=round(vals['kcal'],1); fat=round(vals['fat_g'],1)
|
override = "overwrite_catalog" if overwrite else None
|
||||||
carbs=round(vals['carbs_g'],1); prot=round(vals['protein_g'],1)
|
if item_rows:
|
||||||
cur.execute("SELECT id FROM nutrition_log WHERE profile_id=%s AND date=%s",(pid,iso))
|
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()
|
is_new = not cur.fetchone()
|
||||||
if not is_new:
|
if not is_new:
|
||||||
# UPDATE existing
|
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 WHERE profile_id=%s AND date=%s",
|
cur.execute(
|
||||||
(kcal,prot,fat,carbs,pid,iso))
|
"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:
|
else:
|
||||||
# INSERT new
|
cur.execute(
|
||||||
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)",
|
"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))
|
(str(uuid.uuid4()), pid, iso, kcal, prot, fat, carbs),
|
||||||
|
)
|
||||||
new_entries += 1
|
new_entries += 1
|
||||||
inserted+=1
|
inserted += 1
|
||||||
|
|
||||||
# Phase 2: Increment usage counter for each new entry created
|
|
||||||
for _ in range(new_entries):
|
for _ in range(new_entries):
|
||||||
increment_feature_usage(pid, 'nutrition_entries')
|
increment_feature_usage(pid, 'nutrition_entries')
|
||||||
|
|
||||||
return {"rows_parsed":count,"days_imported":inserted,"new_entries":new_entries,
|
return {
|
||||||
"date_range":{"from":min(days) if days else None,"to":max(days) if days else None}}
|
"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("")
|
@router.post("")
|
||||||
|
|
@ -122,7 +177,7 @@ def create_nutrition(date: str, kcal: float, protein_g: float, fat_g: float, car
|
||||||
# UPDATE existing entry
|
# UPDATE existing entry
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
UPDATE nutrition_log
|
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
|
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))
|
""", (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']}
|
return {"success": True, "mode": "updated", "id": existing['id']}
|
||||||
|
|
@ -145,8 +200,8 @@ def create_nutrition(date: str, kcal: float, protein_g: float, fat_g: float, car
|
||||||
# INSERT new entry
|
# INSERT new entry
|
||||||
new_id = str(uuid.uuid4())
|
new_id = str(uuid.uuid4())
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO nutrition_log (id, profile_id, date, kcal, protein_g, fat_g, carbs_g, source, created)
|
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', CURRENT_TIMESTAMP)
|
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)))
|
""", (new_id, pid, date, round(kcal,1), round(protein_g,1), round(fat_g,1), round(carbs_g,1)))
|
||||||
|
|
||||||
# Phase 2: Increment usage counter
|
# Phase 2: Increment usage counter
|
||||||
|
|
@ -162,8 +217,34 @@ def list_nutrition(limit: int=365, x_profile_id: Optional[str]=Header(default=No
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date DESC LIMIT %s", (pid,limit))
|
"""
|
||||||
return [r2d(r) for r in cur.fetchall()]
|
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),
|
||||||
|
)
|
||||||
|
rows = [r2d(r) for r in cur.fetchall()]
|
||||||
|
dates = [r["date"] for r in rows if r.get("date") is not None]
|
||||||
|
counts = {}
|
||||||
|
if dates:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT date, COUNT(*) AS item_count
|
||||||
|
FROM nutrition_items
|
||||||
|
WHERE profile_id = %s AND date = ANY(%s)
|
||||||
|
GROUP BY date
|
||||||
|
""",
|
||||||
|
(pid, dates),
|
||||||
|
)
|
||||||
|
counts = {str(r["date"]): int(r["item_count"] or 0) for r in cur.fetchall()}
|
||||||
|
for r in rows:
|
||||||
|
r["item_count"] = counts.get(str(r.get("date")), 0)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
@router.get("/by-date/{date}")
|
@router.get("/by-date/{date}")
|
||||||
|
|
@ -228,6 +309,307 @@ def import_history(x_profile_id: Optional[str]=Header(default=None), session: di
|
||||||
return [r2d(r) for r in cur.fetchall()]
|
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),
|
||||||
|
):
|
||||||
|
from data_layer.food_mapping import merge_unmapped_rows, normalize_food_name
|
||||||
|
|
||||||
|
pid = x_profile_id or session["profile_id"]
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT source_name_normalized
|
||||||
|
FROM food_name_mappings
|
||||||
|
WHERE profile_id = %s
|
||||||
|
""",
|
||||||
|
(pid,),
|
||||||
|
)
|
||||||
|
mapped = {r["source_name_normalized"] for r in cur.fetchall()}
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT i.source_name_raw, i.source_name_normalized,
|
||||||
|
COUNT(*) AS count, MIN(i.date) AS first_date, MAX(i.date) AS last_date,
|
||||||
|
MIN(r.id::text) AS matching_recipe_id,
|
||||||
|
MIN(i.quantity_raw) AS sample_quantity_raw
|
||||||
|
FROM nutrition_items i
|
||||||
|
LEFT JOIN food_recipes r
|
||||||
|
ON r.profile_id = i.profile_id AND r.name_normalized = i.source_name_normalized
|
||||||
|
WHERE i.profile_id=%s AND i.food_id IS NULL AND i.recipe_id IS NULL
|
||||||
|
GROUP BY i.source_name_raw, i.source_name_normalized
|
||||||
|
ORDER BY count DESC, i.source_name_normalized
|
||||||
|
""",
|
||||||
|
(pid,),
|
||||||
|
)
|
||||||
|
diary = [r2d(r) | {"kind": "diary"} for r in cur.fetchall()]
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT i.source_name_raw, i.source_name_normalized,
|
||||||
|
COUNT(*) AS count, NULL::date AS first_date, NULL::date AS last_date,
|
||||||
|
MIN(i.quantity_raw) AS sample_quantity_raw
|
||||||
|
FROM food_recipe_ingredients i
|
||||||
|
JOIN food_recipes r ON r.id = i.recipe_id
|
||||||
|
LEFT JOIN food_name_mappings m
|
||||||
|
ON m.profile_id = r.profile_id
|
||||||
|
AND m.source_name_normalized = i.source_name_normalized
|
||||||
|
WHERE r.profile_id = %s AND m.id IS NULL
|
||||||
|
GROUP BY i.source_name_raw, i.source_name_normalized
|
||||||
|
ORDER BY count DESC, i.source_name_normalized
|
||||||
|
""",
|
||||||
|
(pid,),
|
||||||
|
)
|
||||||
|
ings = [r2d(r) | {"kind": "recipe_ingredient"} for r in cur.fetchall()]
|
||||||
|
merged = merge_unmapped_rows(diary + ings)
|
||||||
|
out = []
|
||||||
|
for row in merged:
|
||||||
|
key = row.get("source_name_normalized") or normalize_food_name(row.get("source_name_raw"))
|
||||||
|
if key in mapped:
|
||||||
|
continue
|
||||||
|
out.append(row)
|
||||||
|
out.sort(key=lambda x: (-int(x.get("count") or 0), x.get("source_name_normalized") or ""))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recipes")
|
||||||
|
def list_food_recipes(
|
||||||
|
x_profile_id: Optional[str] = Header(default=None),
|
||||||
|
session: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
from data_layer.food_recipes import list_recipes
|
||||||
|
pid = get_pid(x_profile_id)
|
||||||
|
with get_db() as conn:
|
||||||
|
return list_recipes(get_cursor(conn), pid)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/recipes/import-fddb-lists")
|
||||||
|
async def import_fddb_lists(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
x_profile_id: Optional[str] = Header(default=None),
|
||||||
|
session: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
from bls.recipe_parser import parse_fddb_lists_csv
|
||||||
|
from data_layer.food_recipes import upsert_recipes
|
||||||
|
|
||||||
|
pid = get_pid(x_profile_id)
|
||||||
|
raw = await file.read()
|
||||||
|
if not raw:
|
||||||
|
raise HTTPException(400, "Leere Datei")
|
||||||
|
try:
|
||||||
|
text = raw.decode("utf-8-sig")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
text = raw.decode("latin-1")
|
||||||
|
recipes = parse_fddb_lists_csv(text)
|
||||||
|
if not recipes:
|
||||||
|
raise HTTPException(400, "Keine Rezepte in der Datei erkannt")
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
stats = upsert_recipes(cur, pid, recipes)
|
||||||
|
from data_layer.nutrition_items import rebuild_daily_nutrients
|
||||||
|
for d in stats.pop("dates_linked", []) or []:
|
||||||
|
rebuild_daily_nutrients(cur, pid, d)
|
||||||
|
return {"ok": True, "recipes": len(recipes), **stats}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/recipes/{recipe_id}/apply")
|
||||||
|
def apply_recipe_name(
|
||||||
|
recipe_id: str,
|
||||||
|
body: dict,
|
||||||
|
x_profile_id: Optional[str] = Header(default=None),
|
||||||
|
session: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
from data_layer.food_mapping import normalize_food_name
|
||||||
|
from data_layer.food_recipes import apply_recipe_to_items
|
||||||
|
from data_layer.nutrition_items import dates_for_normalized_name, rebuild_daily_nutrients
|
||||||
|
|
||||||
|
pid = get_pid(x_profile_id)
|
||||||
|
source_name = (body.get("source_name") or "").strip()
|
||||||
|
if not source_name:
|
||||||
|
raise HTTPException(400, "source_name fehlt")
|
||||||
|
norm = normalize_food_name(source_name)
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute("SELECT id FROM food_recipes WHERE id = %s AND profile_id = %s", (recipe_id, pid))
|
||||||
|
if not cur.fetchone():
|
||||||
|
raise HTTPException(404, "Rezept nicht gefunden")
|
||||||
|
n = apply_recipe_to_items(cur, pid, norm, recipe_id)
|
||||||
|
for d in dates_for_normalized_name(cur, pid, norm):
|
||||||
|
rebuild_daily_nutrients(cur, pid, d)
|
||||||
|
return {"ok": True, "items_updated": n}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/food-knowledge")
|
||||||
|
def export_food_knowledge_file(
|
||||||
|
x_profile_id: Optional[str] = Header(default=None),
|
||||||
|
session: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
import json
|
||||||
|
from data_layer.food_knowledge import export_food_knowledge
|
||||||
|
|
||||||
|
pid = get_pid(x_profile_id)
|
||||||
|
with get_db() as conn:
|
||||||
|
bundle = export_food_knowledge(get_cursor(conn), pid)
|
||||||
|
body = json.dumps(bundle, ensure_ascii=False, indent=2, default=str)
|
||||||
|
stamp = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
return Response(
|
||||||
|
content=body.encode("utf-8"),
|
||||||
|
media_type="application/json; charset=utf-8",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="mitai-food-knowledge-{stamp}.json"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/food-knowledge")
|
||||||
|
async def import_food_knowledge_file(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
x_profile_id: Optional[str] = Header(default=None),
|
||||||
|
session: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
import json
|
||||||
|
from data_layer.food_knowledge import import_food_knowledge, parse_food_knowledge_bundle
|
||||||
|
|
||||||
|
pid = get_pid(x_profile_id)
|
||||||
|
raw = await file.read()
|
||||||
|
if not raw:
|
||||||
|
raise HTTPException(400, "Leere Datei")
|
||||||
|
try:
|
||||||
|
data = json.loads(raw.decode("utf-8-sig"))
|
||||||
|
parse_food_knowledge_bundle(data)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(400, str(e)) from e
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(400, f"Ungültiges JSON: {e}") from e
|
||||||
|
with get_db() as conn:
|
||||||
|
return import_food_knowledge(get_cursor(conn), pid, data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import-conflicts/resolve")
|
||||||
|
def resolve_import_conflicts(
|
||||||
|
body: dict,
|
||||||
|
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}")
|
@router.put("/{entry_id}")
|
||||||
def update_nutrition(entry_id: str, kcal: float, protein_g: float, fat_g: float, carbs_g: float,
|
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)):
|
x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||||
|
|
@ -242,7 +624,7 @@ def update_nutrition(entry_id: str, kcal: float, protein_g: float, fat_g: float,
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
UPDATE nutrition_log
|
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
|
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))
|
""", (round(kcal,1), round(protein_g,1), round(fat_g,1), round(carbs_g,1), entry_id, pid))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -109,9 +109,17 @@ def update_profile(pid: str, p: ProfileUpdate, session=Depends(require_auth)):
|
||||||
data["verification_expires"] = None
|
data["verification_expires"] = None
|
||||||
|
|
||||||
nullable_keys = {"goal_weight", "goal_bf_pct", "dob"}
|
nullable_keys = {"goal_weight", "goal_bf_pct", "dob"}
|
||||||
|
allowed_nutrition_policy = {
|
||||||
|
"prompt", "overwrite_catalog", "overwrite_fddb", "keep_existing",
|
||||||
|
}
|
||||||
for k, v in patch.items():
|
for k, v in patch.items():
|
||||||
if k == "email":
|
if k == "email":
|
||||||
continue
|
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:
|
if v is None and k in nullable_keys:
|
||||||
data[k] = None
|
data[k] = None
|
||||||
elif v is not None:
|
elif v is not None:
|
||||||
|
|
|
||||||
110
backend/tests/test_activity_import_feature_enforcement.py
Normal file
110
backend/tests/test_activity_import_feature_enforcement.py
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
"""Feature-Enforcement für Legacy Activity CSV-Import (#37)."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from routers.activity import import_activity_csv
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_activity_csv_blocks_when_limit_reached():
|
||||||
|
upload = AsyncMock()
|
||||||
|
upload.read = AsyncMock(return_value=b"Workout Type,Start\n")
|
||||||
|
|
||||||
|
with patch("routers.activity.get_pid", return_value="profile-1"), patch(
|
||||||
|
"routers.activity.check_feature_access",
|
||||||
|
return_value={
|
||||||
|
"allowed": False,
|
||||||
|
"reason": "limit_exceeded",
|
||||||
|
"used": 30,
|
||||||
|
"limit": 30,
|
||||||
|
},
|
||||||
|
), patch("routers.activity.log_feature_usage"):
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
asyncio.run(
|
||||||
|
import_activity_csv(
|
||||||
|
file=upload,
|
||||||
|
x_profile_id=None,
|
||||||
|
session={"profile_id": "profile-1"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_activity_csv_increments_usage_per_insert(monkeypatch):
|
||||||
|
upload = AsyncMock()
|
||||||
|
upload.read = AsyncMock(
|
||||||
|
return_value=(
|
||||||
|
"Workout Type,Start,Duration,Aktive Energie (kJ),Ruheeinträge (kJ),"
|
||||||
|
"Durchschn. Herzfrequenz (count/min),Max. Herzfrequenz (count/min),Distanz (km),End\n"
|
||||||
|
"Running,2026-07-22 10:00:00 +0200,0:45:00,1000,500,140,160,5.0,\n"
|
||||||
|
).encode("utf-8")
|
||||||
|
)
|
||||||
|
|
||||||
|
increments = []
|
||||||
|
|
||||||
|
class FakeCursor:
|
||||||
|
def execute(self, *args, **kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fetchone(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
class FakeConn:
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
return False
|
||||||
|
|
||||||
|
fake_cur = FakeCursor()
|
||||||
|
|
||||||
|
monkeypatch.setattr("routers.activity.get_pid", lambda _h: "profile-1")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"routers.activity.check_feature_access",
|
||||||
|
lambda *_a, **_k: {"allowed": True, "reason": "ok", "used": 0, "limit": 100},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("routers.activity.log_feature_usage", lambda *_a, **_k: None)
|
||||||
|
monkeypatch.setattr("routers.activity.get_db", lambda: FakeConn())
|
||||||
|
monkeypatch.setattr("routers.activity.get_cursor", lambda _c: fake_cur)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"routers.activity.normalize_activity_start",
|
||||||
|
lambda _s: ("2026-07-22", "10:00:00"),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"routers.activity.get_training_type_for_activity",
|
||||||
|
lambda *_a, **_k: (1, "cardio", None),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"routers.activity.find_activity_duplicate_id",
|
||||||
|
lambda *_a, **_k: None,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"routers.activity.new_activity_id",
|
||||||
|
lambda: "new-id",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"routers.activity.insert_activity_csv_minimal",
|
||||||
|
lambda *_a, **_k: None,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"routers.activity.run_activity_post_write_hooks_import",
|
||||||
|
lambda *_a, **_k: None,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"routers.activity.increment_feature_usage",
|
||||||
|
lambda _pid, feature: increments.append(feature),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
import_activity_csv(
|
||||||
|
file=upload,
|
||||||
|
x_profile_id=None,
|
||||||
|
session={"profile_id": "profile-1"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["inserted"] == 1
|
||||||
|
assert increments == ["activity_entries"]
|
||||||
65
backend/tests/test_bls_parser.py
Normal file
65
backend/tests/test_bls_parser.py
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import time
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from openpyxl import Workbook
|
||||||
|
|
||||||
|
from bls.import_service import should_persist_value
|
||||||
|
from bls.jobs import create_and_check, get_job
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def test_persist_only_numeric_or_trace():
|
||||||
|
assert should_persist_value({"value_num": 1.2, "is_trace": False})
|
||||||
|
assert should_persist_value({"value_num": None, "is_trace": True})
|
||||||
|
assert not should_persist_value({"value_num": None, "is_trace": False})
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_job_check_does_not_need_http():
|
||||||
|
data = _xlsx([
|
||||||
|
["BLS Code", "Name", "Food name", "ENERCC Energie [kcal/100g]", "ENERCC Herkunft", "ENERCC Referenz"],
|
||||||
|
["C131000", "Hafer roh", "Oats raw", 350, "Analyse", "MRI"],
|
||||||
|
])
|
||||||
|
job_id = create_and_check("foods", data)
|
||||||
|
job = None
|
||||||
|
for _ in range(80):
|
||||||
|
job = get_job(job_id)
|
||||||
|
if job and job["status"] in ("checked", "error"):
|
||||||
|
break
|
||||||
|
time.sleep(0.05)
|
||||||
|
assert job is not None
|
||||||
|
assert job["status"] == "checked", job.get("error")
|
||||||
|
assert job["check"]["foods"] == 1
|
||||||
26
backend/tests/test_csv_mapping_validation.py
Normal file
26
backend/tests/test_csv_mapping_validation.py
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
"""Tests für CSV-Vorlagen-Validierung (#71)."""
|
||||||
|
|
||||||
|
from csv_parser.template_validator import validate_csv_template
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_csv_template_rejects_invalid_row_processing():
|
||||||
|
report = validate_csv_template(
|
||||||
|
"nutrition",
|
||||||
|
{"Datum": "date", "kcal": "kcal"},
|
||||||
|
None,
|
||||||
|
{"group_by": ["date"], "aggregates": {"kcal": "not_an_op"}},
|
||||||
|
["Datum", "kcal"],
|
||||||
|
)
|
||||||
|
assert report["valid"] is False
|
||||||
|
assert any(e.get("code") == "invalid_import_row_processing" for e in report["errors"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_csv_template_accepts_nutrition_aggregation():
|
||||||
|
report = validate_csv_template(
|
||||||
|
"nutrition",
|
||||||
|
{"Datum": "date", "kcal": "kcal", "protein": "protein_g"},
|
||||||
|
None,
|
||||||
|
{"group_by": ["date"], "aggregates": {"kcal": "sum", "protein_g": "sum"}},
|
||||||
|
["Datum", "kcal", "protein"],
|
||||||
|
)
|
||||||
|
assert report["valid"] is True
|
||||||
42
backend/tests/test_food_knowledge.py
Normal file
42
backend/tests/test_food_knowledge.py
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
from data_layer.food_knowledge import (
|
||||||
|
BUNDLE_FORMAT,
|
||||||
|
parse_food_knowledge_bundle,
|
||||||
|
portable_mapping,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_rejects_unknown_format():
|
||||||
|
try:
|
||||||
|
parse_food_knowledge_bundle({"format": "other", "version": 1})
|
||||||
|
except ValueError as e:
|
||||||
|
assert "Mitai-Zuordnungsdatei" in str(e)
|
||||||
|
else:
|
||||||
|
raise AssertionError("expected ValueError")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_accepts_bundle():
|
||||||
|
data = parse_food_knowledge_bundle({
|
||||||
|
"format": BUNDLE_FORMAT,
|
||||||
|
"version": 1,
|
||||||
|
"mappings": [],
|
||||||
|
"recipes": [],
|
||||||
|
})
|
||||||
|
assert data["version"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_portable_mapping_drops_ids():
|
||||||
|
row = portable_mapping({
|
||||||
|
"id": 99,
|
||||||
|
"food_id": "uuid-here",
|
||||||
|
"source_system": "fddb",
|
||||||
|
"source_name_raw": "Haferflocken, Großblatt",
|
||||||
|
"source_name_normalized": "haferflocken großblatt",
|
||||||
|
"bls_code": "C131000",
|
||||||
|
"food_name_de": "Hafer roh",
|
||||||
|
"catalog_kind": "official_bls",
|
||||||
|
"external_key": None,
|
||||||
|
})
|
||||||
|
assert "id" not in row
|
||||||
|
assert "food_id" not in row
|
||||||
|
assert row["bls_code"] == "C131000"
|
||||||
|
assert row["source_name_raw"] == "Haferflocken, Großblatt"
|
||||||
47
backend/tests/test_food_mapping.py
Normal file
47
backend/tests/test_food_mapping.py
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
from csv_parser.executor import guess_nutrition_item_fields
|
||||||
|
from data_layer.food_mapping import merge_unmapped_rows, normalize_food_name, parse_quantity, parse_quantity_g
|
||||||
|
from data_layer.nutrition_items import macros_differ
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_strips_leading_quantity():
|
||||||
|
assert normalize_food_name("50 g Hähnchen") == "hähnchen"
|
||||||
|
assert normalize_food_name(" Vollmilch 3,5% ") == "vollmilch 3.5%"
|
||||||
|
assert normalize_food_name("1 ml Olivenöl") == normalize_food_name("2 ml Olivenöl") == "olivenöl"
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_unmapped_collapses_quantity_variants():
|
||||||
|
rows = merge_unmapped_rows([
|
||||||
|
{"source_name_raw": "1 ml Olivenöl", "source_name_normalized": "1 ml olivenöl", "count": 3, "kind": "diary"},
|
||||||
|
{"source_name_raw": "2 ml Olivenöl", "source_name_normalized": "2 ml olivenöl", "count": 1, "kind": "diary"},
|
||||||
|
])
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]["source_name_normalized"] == "olivenöl"
|
||||||
|
assert rows[0]["count"] == 4
|
||||||
|
assert rows[0]["source_name_raw"].lower() == "olivenöl"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_quantity_g():
|
||||||
|
assert parse_quantity_g("150 g") == 150.0
|
||||||
|
assert parse_quantity_g("150") == 150.0
|
||||||
|
assert parse_quantity_g("1 kg") == 1000.0
|
||||||
|
assert parse_quantity_g("5 ml") == 5.0
|
||||||
|
assert parse_quantity_g("1 Stück") is None
|
||||||
|
assert parse_quantity_g("1 Stück", 60) == 60.0
|
||||||
|
assert parse_quantity_g("2 EL") == 30.0
|
||||||
|
assert parse_quantity("1 Scheibe")["needs_unit_map"] is True
|
||||||
|
assert parse_quantity("2 EL")["unit"] == "el"
|
||||||
|
|
||||||
|
|
||||||
|
def test_guess_fddb_bezeichnung_without_template_mapping():
|
||||||
|
name, qty = guess_nutrition_item_fields({
|
||||||
|
"bezeichnung": "50 g Hähnchen",
|
||||||
|
"menge": "50 g",
|
||||||
|
"kj": "800",
|
||||||
|
})
|
||||||
|
assert name == "50 g Hähnchen"
|
||||||
|
assert qty == "50 g"
|
||||||
|
|
||||||
|
|
||||||
|
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})
|
||||||
43
backend/tests/test_recipe_parser.py
Normal file
43
backend/tests/test_recipe_parser.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
from bls.recipe_parser import parse_fddb_lists_csv, parse_fddb_produkte
|
||||||
|
from data_layer.food_mapping import normalize_food_name
|
||||||
|
|
||||||
|
|
||||||
|
PORRIDGE = (
|
||||||
|
"160 g Apfel, Braeburn, 5 ml Omega-3 Vegan Algenöl, 100 g Wildheidelbeeren, "
|
||||||
|
"30 g Haferflocken, 100% Hafer-Vollkorn, 40 g Hafer Flocken, Großblatt, 11 g Flohsamenschalen"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_fddb_produkte_splits_on_next_quantity_not_commas():
|
||||||
|
ings = parse_fddb_produkte(PORRIDGE)
|
||||||
|
names = [i["source_name_raw"] for i in ings]
|
||||||
|
assert names == [
|
||||||
|
"Apfel, Braeburn",
|
||||||
|
"Omega-3 Vegan Algenöl",
|
||||||
|
"Wildheidelbeeren",
|
||||||
|
"Haferflocken, 100% Hafer-Vollkorn",
|
||||||
|
"Hafer Flocken, Großblatt",
|
||||||
|
"Flohsamenschalen",
|
||||||
|
]
|
||||||
|
assert ings[0]["quantity_g"] == 160.0
|
||||||
|
assert ings[1]["quantity_g"] == 5.0
|
||||||
|
assert ings[1]["quantity_raw"] == "5 ml"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_fddb_lists_csv_porridge_and_portions():
|
||||||
|
text = (
|
||||||
|
"name;beschreibung;anzahl_portionen;zeit_vorbereitung;zeit_kochen;produkte;\n"
|
||||||
|
'"!PorridgeBreakfast ";"";"1";"0";"0";"' + PORRIDGE + '";\n'
|
||||||
|
'"Aloo gobi";"";"4";"0";"0";"32 ml Rapso Rapsöl, 83 g Zwiebel, frisch";\n'
|
||||||
|
)
|
||||||
|
recipes = parse_fddb_lists_csv(text)
|
||||||
|
assert len(recipes) == 2
|
||||||
|
assert recipes[0]["name_raw"] == "!PorridgeBreakfast"
|
||||||
|
assert recipes[0]["name_normalized"] == normalize_food_name("PorridgeBreakfast")
|
||||||
|
assert len(recipes[0]["ingredients"]) == 6
|
||||||
|
assert recipes[1]["portions"] == 4.0
|
||||||
|
assert recipes[1]["ingredients"][0]["source_name_raw"] == "Rapso Rapsöl"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_strips_list_bang_prefix():
|
||||||
|
assert normalize_food_name("!PorridgeBreakfast") == normalize_food_name("PorridgeBreakfast")
|
||||||
|
|
@ -7,9 +7,9 @@ Semantic Versioning: MAJOR.MINOR.PATCH
|
||||||
- PATCH: Bugfix, kleine Änderung, Refactor
|
- PATCH: Bugfix, kleine Änderung, Refactor
|
||||||
"""
|
"""
|
||||||
|
|
||||||
APP_VERSION = "0.9t"
|
APP_VERSION = "0.9v"
|
||||||
BUILD_DATE = "2026-04-20"
|
BUILD_DATE = "2026-09-12"
|
||||||
DB_SCHEMA_VERSION = "20260409c" # 048/049 vitals_baseline.source csv + SAVEPOINT Import
|
DB_SCHEMA_VERSION = "20260912" # 064 mapping units
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
"auth": "1.2.0",
|
"auth": "1.2.0",
|
||||||
|
|
@ -19,8 +19,9 @@ MODULE_VERSIONS = {
|
||||||
"weight": "1.0.3",
|
"weight": "1.0.3",
|
||||||
"circumference": "1.0.1",
|
"circumference": "1.0.1",
|
||||||
"caliper": "1.0.1",
|
"caliper": "1.0.1",
|
||||||
"activity": "1.2.0", # GET /activity: optional days= window + limit
|
"activity": "1.2.1", # Legacy CSV import: activity_entries feature enforcement
|
||||||
"nutrition": "1.0.2",
|
"nutrition": "1.2.2", # mapping save + manual food + units
|
||||||
|
"bls": "1.0.1",
|
||||||
"photos": "1.0.0",
|
"photos": "1.0.0",
|
||||||
"insights": "1.3.0",
|
"insights": "1.3.0",
|
||||||
"prompts": "1.1.0",
|
"prompts": "1.1.0",
|
||||||
|
|
@ -31,11 +32,34 @@ MODULE_VERSIONS = {
|
||||||
"membership": "2.1.0",
|
"membership": "2.1.0",
|
||||||
"workflow": "0.7.0", # Part 3: Inline Prompts (reference + inline mode)
|
"workflow": "0.7.0", # Part 3: Inline Prompts (reference + inline mode)
|
||||||
"app_dashboard": "1.17.1", # history_overview_viz: Bereichs-Kacheln einzeln per show_section_*
|
"app_dashboard": "1.17.1", # history_overview_viz: Bereichs-Kacheln einzeln per show_section_*
|
||||||
"csv_import": "0.3.2", # Import-Fehler: enrich_row_error / freundlichere 500-Hinweise
|
"csv_import": "0.4.0", # Mapping validation on copy/import; validate endpoint; error_details UI
|
||||||
"admin_csv_templates": "0.3.0", # POST /validate + Speichern nur bei valid (422 + warnings in Response)
|
"admin_csv_templates": "0.3.1", # Format check includes import_row_processing parity
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
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",
|
||||||
|
"BLS-Import als Hintergrundjob (kein Proxy-504)",
|
||||||
|
"Zuordnen: Katalog-Suche nach Name (Popup), nicht nach BLS-Code",
|
||||||
|
"FDDB-Listen/Rezepte importieren und Tagebuchzeilen in Zutaten auflösen",
|
||||||
|
"Zuordnungen und Listen als JSON exportieren/importieren (Dev → Prod)",
|
||||||
|
"Zuordnen: Mapping unabhängig vom Nährwert-Rebuild; eigener Katalogeintrag; Mengeneinheiten",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.9u",
|
||||||
|
"date": "2026-07-24",
|
||||||
|
"changes": [
|
||||||
|
"Gitea #37: Activity legacy CSV import — activity_entries feature enforcement + UsageBadge UI",
|
||||||
|
"Gitea #38: Nutrition FDDB import — UsageBadge + deaktivierte Import-Zone bei Limit",
|
||||||
|
"Gitea #71: Universal CSV — Dry-Run/Format-Check Parität import_row_processing; Mapping-Validierung bei Copy/Import; strukturierte error_details in Nutzer-UI",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.9t",
|
"version": "0.9t",
|
||||||
"date": "2026-04-20",
|
"date": "2026-04-20",
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
|---------|--------|
|
|---------|--------|
|
||||||
| Hauptnavigation | `frontend/src/config/appNav.js` (`getMainNavItems`) |
|
| Hauptnavigation | `frontend/src/config/appNav.js` (`getMainNavItems`) |
|
||||||
| Active-State Bottom + Sidebar | `frontend/src/App.jsx` (`navItemActive`), `frontend/src/components/DesktopSidebar.jsx` |
|
| 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`, … |
|
| 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` |
|
| 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` |
|
| KI-Analyse Layout | `frontend/src/pages/Analysis.jsx` + `.analysis-split*` in `app.css` |
|
||||||
|
|
|
||||||
25
docs/issues/gitea-bls-issue-body.md
Normal file
25
docs/issues/gitea-bls-issue-body.md
Normal file
|
|
@ -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
|
||||||
20
docs/issues/issue-bls-food-mapping.md
Normal file
20
docs/issues/issue-bls-food-mapping.md
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# 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
|
||||||
|
- Zuordnen über **Namenssuche im Popup** (kein BLS-Code-Lookup durch den Nutzer)
|
||||||
|
- FDDB-Listen-CSV importieren; Tagebuch-Rezeptzeilen in Zutaten auflösen
|
||||||
|
- Zuordnungen und Listen als JSON zwischen Instanzen übertragen
|
||||||
|
- Eigener Katalogeintrag im Suchpopup; Gramm pro Stück/EL/… am Mapping
|
||||||
|
|
@ -7,10 +7,10 @@ server {
|
||||||
proxy_pass http://backend:8000/api/;
|
proxy_pass http://backend:8000/api/;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
client_max_body_size 20M;
|
client_max_body_size 50M;
|
||||||
proxy_read_timeout 300s;
|
proxy_read_timeout 600s;
|
||||||
proxy_connect_timeout 60s;
|
proxy_connect_timeout 60s;
|
||||||
proxy_send_timeout 60s;
|
proxy_send_timeout 600s;
|
||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,10 @@ import CustomGoalsPage from './pages/CustomGoalsPage'
|
||||||
import UniversalCsvImportPage from './pages/UniversalCsvImportPage'
|
import UniversalCsvImportPage from './pages/UniversalCsvImportPage'
|
||||||
import AdminCsvTemplatesPage from './pages/AdminCsvTemplatesPage'
|
import AdminCsvTemplatesPage from './pages/AdminCsvTemplatesPage'
|
||||||
import AdminCsvTemplateEditorPage from './pages/AdminCsvTemplateEditorPage'
|
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 WorkflowEditorPage from './pages/WorkflowEditorPage'
|
import WorkflowEditorPage from './pages/WorkflowEditorPage'
|
||||||
import DesktopSidebar from './components/DesktopSidebar'
|
import DesktopSidebar from './components/DesktopSidebar'
|
||||||
import { getMainNavItems } from './config/appNav'
|
import { getMainNavItems } from './config/appNav'
|
||||||
|
|
@ -267,6 +271,10 @@ function AppShell() {
|
||||||
<Route path="reference-value-types" element={<AdminReferenceValueTypesPage/>}/>
|
<Route path="reference-value-types" element={<AdminReferenceValueTypesPage/>}/>
|
||||||
<Route path="csv-templates" element={<AdminCsvTemplatesPage />} />
|
<Route path="csv-templates" element={<AdminCsvTemplatesPage />} />
|
||||||
<Route path="csv-templates/:id" element={<AdminCsvTemplateEditorPage />} />
|
<Route path="csv-templates/:id" element={<AdminCsvTemplateEditorPage />} />
|
||||||
|
<Route path="bls-import" element={<AdminBlsImportPage />} />
|
||||||
|
<Route path="bls-foods" element={<AdminBlsFoodsPage />} />
|
||||||
|
<Route path="food-attributes" element={<AdminFoodAttributesPage />} />
|
||||||
|
<Route path="food-mappings" element={<AdminFoodMappingsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/workflow-editor/:id" element={<WorkflowEditorPage/>}/>
|
<Route path="/workflow-editor/:id" element={<WorkflowEditorPage/>}/>
|
||||||
|
|
|
||||||
76
frontend/src/components/CsvImportErrorDetails.jsx
Normal file
76
frontend/src/components/CsvImportErrorDetails.jsx
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
/**
|
||||||
|
* Strukturierte Zeilenfehler aus Universal-CSV-Import (error_details mit hint/code).
|
||||||
|
*/
|
||||||
|
export default function CsvImportErrorDetails({ errors, title, defaultOpen = true, maxVisible = 20 }) {
|
||||||
|
if (!errors?.length) return null
|
||||||
|
|
||||||
|
const shown = errors.slice(0, maxVisible)
|
||||||
|
const rest = errors.length - shown.length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details
|
||||||
|
className="card"
|
||||||
|
style={{ marginBottom: 16, padding: 16, cursor: 'pointer' }}
|
||||||
|
open={defaultOpen}
|
||||||
|
>
|
||||||
|
<summary style={{ fontWeight: 600, color: 'var(--text1)' }}>
|
||||||
|
{title || `Zeilenfehler (${errors.length})`}
|
||||||
|
</summary>
|
||||||
|
<ul
|
||||||
|
style={{
|
||||||
|
margin: '12px 0 0 0',
|
||||||
|
padding: 0,
|
||||||
|
listStyle: 'none',
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{shown.map((err, i) => {
|
||||||
|
const row = err.row ?? err.row_index
|
||||||
|
const message = err.error ?? err.message ?? (typeof err === 'string' ? err : JSON.stringify(err))
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
padding: '10px 12px',
|
||||||
|
marginBottom: 8,
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
borderRadius: 8,
|
||||||
|
borderLeft: '3px solid var(--danger)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ color: 'var(--text1)' }}>
|
||||||
|
{row != null && (
|
||||||
|
<strong style={{ marginRight: 6 }}>Zeile {row}:</strong>
|
||||||
|
)}
|
||||||
|
{message}
|
||||||
|
{err.code && (
|
||||||
|
<code
|
||||||
|
style={{
|
||||||
|
marginLeft: 8,
|
||||||
|
fontSize: 11,
|
||||||
|
color: 'var(--text3)',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
padding: '1px 6px',
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{err.code}
|
||||||
|
</code>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{err.hint && (
|
||||||
|
<div style={{ marginTop: 6, fontSize: 12, color: 'var(--text2)' }}>{err.hint}</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
{rest > 0 && (
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '8px 0 0' }}>
|
||||||
|
… und {rest} weitere Fehler (Details in der Import-Historie).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</details>
|
||||||
|
)
|
||||||
|
}
|
||||||
244
frontend/src/components/FoodSearchModal.jsx
Normal file
244
frontend/src/components/FoodSearchModal.jsx
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { api } from '../utils/api'
|
||||||
|
|
||||||
|
const COUNT_UNITS = new Set(['stück', 'scheibe', 'portion', 'becher', 'tasse'])
|
||||||
|
const UNIT_LABEL = {
|
||||||
|
stück: 'Stück', scheibe: 'Scheibe', portion: 'Portion',
|
||||||
|
becher: 'Becher', tasse: 'Tasse', el: 'EL', tl: 'TL', prise: 'Prise',
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectUnit(...texts) {
|
||||||
|
for (const text of texts) {
|
||||||
|
const m = String(text || '').match(
|
||||||
|
/(\d+(?:[.,]\d+)?)\s*(stück|stk|st\.?|scheibe|portion(?:en)?|becher|tasse|el|esslöffel|tl|teelöffel|prise)\b/i
|
||||||
|
)
|
||||||
|
if (!m) continue
|
||||||
|
const raw = m[2].toLowerCase().replace(/\.$/, '')
|
||||||
|
const unit = raw.startsWith('st') && !raw.startsWith('scheibe') ? 'stück'
|
||||||
|
: raw.startsWith('ess') || raw === 'el' ? 'el'
|
||||||
|
: raw.startsWith('tee') || raw === 'tl' ? 'tl'
|
||||||
|
: raw.startsWith('portion') ? 'portion'
|
||||||
|
: raw
|
||||||
|
return unit
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FoodSearchModal({ title, initialQuery, quantityHint, onSelect, onClose }) {
|
||||||
|
const [q, setQ] = useState(initialQuery || '')
|
||||||
|
const [hits, setHits] = useState([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
const [manual, setManual] = useState({
|
||||||
|
name_de: initialQuery || '',
|
||||||
|
kcal: '', protein_g: '', fat_g: '', carbs_g: '',
|
||||||
|
})
|
||||||
|
const suggestedUnit = detectUnit(quantityHint, initialQuery)
|
||||||
|
const [gramsPerUnit, setGramsPerUnit] = useState(
|
||||||
|
suggestedUnit === 'el' ? '15' : suggestedUnit === 'tl' ? '5' : ''
|
||||||
|
)
|
||||||
|
const inputRef = useRef(null)
|
||||||
|
const timer = useRef(null)
|
||||||
|
|
||||||
|
const extras = () => {
|
||||||
|
const g = parseFloat(String(gramsPerUnit).replace(',', '.'))
|
||||||
|
if (!suggestedUnit || !g || g <= 0) return {}
|
||||||
|
return { grams_per_unit: g, source_unit: suggestedUnit }
|
||||||
|
}
|
||||||
|
|
||||||
|
const runSearch = async (term) => {
|
||||||
|
const query = (term || '').trim()
|
||||||
|
if (query.length < 2) {
|
||||||
|
setHits([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
setHits(await api.searchBlsFoods(query, 30))
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message)
|
||||||
|
setHits([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus()
|
||||||
|
inputRef.current?.select()
|
||||||
|
if ((initialQuery || '').trim().length >= 2) runSearch(initialQuery)
|
||||||
|
const onKey = (e) => { if (e.key === 'Escape') onClose() }
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKey)
|
||||||
|
clearTimeout(timer.current)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onChange = (value) => {
|
||||||
|
setQ(value)
|
||||||
|
clearTimeout(timer.current)
|
||||||
|
timer.current = setTimeout(() => runSearch(value), 250)
|
||||||
|
}
|
||||||
|
|
||||||
|
const createManual = async () => {
|
||||||
|
const name = (manual.name_de || q || '').trim()
|
||||||
|
if (!name) {
|
||||||
|
setError('Name für den Eintrag fehlt')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCreating(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const food = await api.createUserFood({
|
||||||
|
name_de: name,
|
||||||
|
macros_per_100g: {
|
||||||
|
kcal: parseFloat(String(manual.kcal).replace(',', '.')) || 0,
|
||||||
|
protein_g: parseFloat(String(manual.protein_g).replace(',', '.')) || 0,
|
||||||
|
fat_g: parseFloat(String(manual.fat_g).replace(',', '.')) || 0,
|
||||||
|
carbs_g: parseFloat(String(manual.carbs_g).replace(',', '.')) || 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
onSelect(food, extras())
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message)
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unitLabel = UNIT_LABEL[suggestedUnit] || suggestedUnit
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 20000,
|
||||||
|
padding: 16,
|
||||||
|
}}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="food-search-title"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
width: '100%', maxWidth: 520, maxHeight: 'min(88vh, 720px)',
|
||||||
|
background: 'var(--surface)', borderRadius: 16,
|
||||||
|
boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '14px 16px', borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<h2 id="food-search-title" className="card-title" style={{ margin: 0, fontSize: 16 }}>{title || 'Lebensmittel suchen'}</h2>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={onClose}>Schließen</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '12px 16px' }}>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className="form-input"
|
||||||
|
style={{ width: '100%', textAlign: 'left' }}
|
||||||
|
placeholder="Name eingeben, z. B. Haferflocken"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '8px 0 0' }}>
|
||||||
|
Suche nach dem Namen. Fehlt der Treffer, legst du unten einen eigenen Eintrag an.
|
||||||
|
</p>
|
||||||
|
{suggestedUnit && (
|
||||||
|
<label style={{ display: 'block', marginTop: 10, fontSize: 13 }}>
|
||||||
|
1 {unitLabel} ={' '}
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
type="number"
|
||||||
|
min="0.1"
|
||||||
|
step="0.1"
|
||||||
|
style={{ width: 90, display: 'inline-block', textAlign: 'left' }}
|
||||||
|
value={gramsPerUnit}
|
||||||
|
onChange={(e) => setGramsPerUnit(e.target.value)}
|
||||||
|
placeholder={COUNT_UNITS.has(suggestedUnit) ? 'z. B. 60' : ''}
|
||||||
|
/>
|
||||||
|
{' '}g
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ overflowY: 'auto', padding: '0 16px 16px', flex: 1 }}>
|
||||||
|
{loading && <p style={{ fontSize: 13, color: 'var(--text2)' }}>Suche…</p>}
|
||||||
|
{error && <p style={{ color: 'var(--danger)', fontSize: 13 }}>{error}</p>}
|
||||||
|
{!loading && q.trim().length >= 2 && hits.length === 0 && (
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text3)' }}>Kein Treffer im Katalog.</p>
|
||||||
|
)}
|
||||||
|
{hits.map((h) => (
|
||||||
|
<button
|
||||||
|
key={h.id}
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary btn-full"
|
||||||
|
style={{ marginTop: 8, justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '10px 12px' }}
|
||||||
|
onClick={() => onSelect(h, extras())}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<strong style={{ display: 'block' }}>{h.name_de}</strong>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text3)' }}>
|
||||||
|
{h.bls_code ? `BLS ${h.bls_code}` : 'ohne Code'}
|
||||||
|
{h.food_group ? ` · Gruppe ${h.food_group}` : ''}
|
||||||
|
{h.catalog_kind !== 'official_bls' ? ' · manuell' : ''}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div style={{ marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
|
||||||
|
{!showCreate ? (
|
||||||
|
<button type="button" className="btn btn-secondary btn-full" onClick={() => {
|
||||||
|
setShowCreate(true)
|
||||||
|
setManual((s) => ({ ...s, name_de: s.name_de || q || initialQuery || '' }))
|
||||||
|
}}>
|
||||||
|
Eigenes Lebensmittel anlegen
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<p style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>Eigener Eintrag (pro 100 g)</p>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
style={{ width: '100%', textAlign: 'left', marginBottom: 8 }}
|
||||||
|
placeholder="Name"
|
||||||
|
value={manual.name_de}
|
||||||
|
onChange={(e) => setManual({ ...manual, name_de: e.target.value })}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||||
|
{[['kcal', 'kcal'], ['protein_g', 'Protein g'], ['fat_g', 'Fett g'], ['carbs_g', 'Kohlenhydrate g']].map(([key, label]) => (
|
||||||
|
<label key={key} style={{ fontSize: 12, color: 'var(--text2)' }}>
|
||||||
|
{label}
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.1"
|
||||||
|
style={{ width: '100%', textAlign: 'left', marginTop: 4 }}
|
||||||
|
value={manual[key]}
|
||||||
|
onChange={(e) => setManual({ ...manual, [key]: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-full"
|
||||||
|
style={{ marginTop: 10 }}
|
||||||
|
disabled={creating}
|
||||||
|
onClick={createManual}
|
||||||
|
>
|
||||||
|
{creating ? 'Speichere…' : 'Anlegen und zuordnen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
364
frontend/src/components/NutritionFoodMap.jsx
Normal file
364
frontend/src/components/NutritionFoodMap.jsx
Normal file
|
|
@ -0,0 +1,364 @@
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { api } from '../utils/api'
|
||||||
|
import FoodSearchModal from './FoodSearchModal'
|
||||||
|
|
||||||
|
function suggestQuery(raw) {
|
||||||
|
let s = (raw || '').replace(/^\s*[!]?\s*\d+(?:[.,]\d+)?\s*(?:g|kg|ml|l|stück|stk)?\s*/i, '').trim()
|
||||||
|
s = s.replace(/^!+/, '').trim()
|
||||||
|
const comma = s.indexOf(',')
|
||||||
|
if (comma > 2) s = s.slice(0, comma).trim()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecipePickModal({ recipes, sourceName, onPick, onClose }) {
|
||||||
|
const [q, setQ] = useState(suggestQuery(sourceName))
|
||||||
|
const filtered = recipes.filter((r) => {
|
||||||
|
const hay = `${r.name_raw || ''} ${r.name_normalized || ''}`.toLowerCase()
|
||||||
|
return !q.trim() || hay.includes(q.trim().toLowerCase())
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 20000, padding: 16,
|
||||||
|
}}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
width: '100%', maxWidth: 480, maxHeight: 'min(88vh, 560px)',
|
||||||
|
background: 'var(--surface)', borderRadius: 16,
|
||||||
|
boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '14px 16px', borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<h2 className="card-title" style={{ margin: 0, fontSize: 16 }}>Eigenes Rezept wählen</h2>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={onClose}>Schließen</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '12px 16px' }}>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
style={{ width: '100%', textAlign: 'left' }}
|
||||||
|
autoFocus
|
||||||
|
placeholder="Rezeptname filtern"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ overflowY: 'auto', padding: '0 16px 16px', flex: 1 }}>
|
||||||
|
{filtered.length === 0 && <p style={{ fontSize: 13, color: 'var(--text3)' }}>Kein passendes Rezept. Zuerst Listen-CSV importieren.</p>}
|
||||||
|
{filtered.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r.id}
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary btn-full"
|
||||||
|
style={{ marginTop: 8, justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '10px 12px' }}
|
||||||
|
onClick={() => onPick(r.id)}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<strong style={{ display: 'block' }}>{r.name_raw}</strong>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text3)' }}>
|
||||||
|
{(r.ingredients || []).length} Zutaten
|
||||||
|
{r.portions ? ` · ${r.portions} Portionen` : ''}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NutritionFoodMap({ onChanged }) {
|
||||||
|
const [unmapped, setUnmapped] = useState([])
|
||||||
|
const [learned, setLearned] = useState([])
|
||||||
|
const [recipes, setRecipes] = useState([])
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [notice, setNotice] = useState(null)
|
||||||
|
const [saving, setSaving] = useState(null)
|
||||||
|
const [searchFor, setSearchFor] = useState(null)
|
||||||
|
const [recipeFor, setRecipeFor] = useState(null)
|
||||||
|
const [importing, setImporting] = useState(false)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const listRef = useRef(null)
|
||||||
|
const bundleRef = useRef(null)
|
||||||
|
const loadGen = useRef(0)
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
const gen = ++loadGen.current
|
||||||
|
try {
|
||||||
|
const [u, m, r] = await Promise.all([
|
||||||
|
api.listUnmappedFoods(),
|
||||||
|
api.listMyFoodMappings(),
|
||||||
|
api.listNutritionRecipes().catch(() => []),
|
||||||
|
])
|
||||||
|
if (gen !== loadGen.current) return
|
||||||
|
setUnmapped(u)
|
||||||
|
setLearned(m)
|
||||||
|
setRecipes(Array.isArray(r) ? r : [])
|
||||||
|
} catch (e) {
|
||||||
|
if (gen !== loadGen.current) return
|
||||||
|
setError(e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [])
|
||||||
|
|
||||||
|
const assign = async (sourceName, foodId, extras = {}) => {
|
||||||
|
setSaving(sourceName)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await api.upsertMyFoodMapping({
|
||||||
|
source_name: sourceName,
|
||||||
|
food_id: foodId,
|
||||||
|
grams_per_unit: extras.grams_per_unit || null,
|
||||||
|
source_unit: extras.source_unit || null,
|
||||||
|
})
|
||||||
|
setSearchFor(null)
|
||||||
|
setNotice(`Zuordnung gespeichert: ${sourceName}`)
|
||||||
|
await load()
|
||||||
|
onChanged?.()
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message)
|
||||||
|
} finally {
|
||||||
|
setSaving(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyRecipe = async (sourceName, recipeId) => {
|
||||||
|
setSaving(sourceName)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await api.applyNutritionRecipe(recipeId, sourceName)
|
||||||
|
setRecipeFor(null)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportBundle = async () => {
|
||||||
|
setBusy(true)
|
||||||
|
setError(null)
|
||||||
|
setNotice(null)
|
||||||
|
try {
|
||||||
|
await api.exportFoodKnowledge()
|
||||||
|
setNotice('Zuordnungen und Listen als JSON heruntergeladen.')
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message)
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const importBundle = async (file) => {
|
||||||
|
if (!file) return
|
||||||
|
setBusy(true)
|
||||||
|
setError(null)
|
||||||
|
setNotice(null)
|
||||||
|
try {
|
||||||
|
const res = await api.importFoodKnowledge(file)
|
||||||
|
await load()
|
||||||
|
onChanged?.()
|
||||||
|
const skip = res.mappings_skipped ? `, ${res.mappings_skipped} ohne Katalogtreffer` : ''
|
||||||
|
const lists = (res.inserted || 0) + (res.updated || 0)
|
||||||
|
setNotice(`${res.mappings || 0} Zuordnungen und ${lists} Listen übernommen${skip}.`)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message)
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const importLists = async (file) => {
|
||||||
|
if (!file) return
|
||||||
|
setImporting(true)
|
||||||
|
setError(null)
|
||||||
|
setNotice(null)
|
||||||
|
try {
|
||||||
|
const res = await api.importFddbLists(file)
|
||||||
|
await load()
|
||||||
|
onChanged?.()
|
||||||
|
setNotice(`${res.recipes} Listen importiert, ${res.items_linked || 0} Tagebuchzeilen als Rezept verknüpft. Offene Zeilen sind jetzt die Zutaten.`)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message)
|
||||||
|
} finally {
|
||||||
|
setImporting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card section-gap">
|
||||||
|
<div className="card-title">Lebensmittel zuordnen</div>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginBottom: 12 }}>
|
||||||
|
Tippe auf „Im Katalog suchen“ — du suchst nach dem Namen, nicht nach einem Code.
|
||||||
|
Eigene FDDB-Listen zuerst importieren, dann wird die Tagebuchzeile in Zutaten aufgelöst.
|
||||||
|
Für den Umzug nach Prod: Zuordnungen und Listen als JSON exportieren und dort wieder importieren.
|
||||||
|
</p>
|
||||||
|
{error && <div style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 10 }}>{error}</div>}
|
||||||
|
{notice && <div style={{ fontSize: 13, color: 'var(--accent-dark)', marginBottom: 10 }}>{notice}</div>}
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={listRef}
|
||||||
|
type="file"
|
||||||
|
accept=".csv,text/csv"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0]
|
||||||
|
e.target.value = ''
|
||||||
|
if (f) importLists(f)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary btn-full"
|
||||||
|
disabled={importing}
|
||||||
|
onClick={() => listRef.current?.click()}
|
||||||
|
>
|
||||||
|
{importing ? 'Importiere Listen…' : 'FDDB-Listen / Rezepte importieren'}
|
||||||
|
</button>
|
||||||
|
{recipes.length > 0 && (
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text3)', marginTop: 8 }}>{recipes.length} eigene Listen geladen</p>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={bundleRef}
|
||||||
|
type="file"
|
||||||
|
accept=".json,application/json"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0]
|
||||||
|
e.target.value = ''
|
||||||
|
if (f) importBundle(f)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
|
||||||
|
<button type="button" className="btn btn-secondary" disabled={busy} onClick={exportBundle}>
|
||||||
|
Zuordnungen & Listen exportieren
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary" disabled={busy} onClick={() => bundleRef.current?.click()}>
|
||||||
|
Zuordnungen & Listen importieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style={{ fontSize: 14, margin: '16px 0 8px' }}>Offen ({unmapped.length})</h3>
|
||||||
|
{unmapped.length === 0 && <p className="muted">Keine offenen Bezeichner.</p>}
|
||||||
|
{unmapped.map((u) => {
|
||||||
|
const key = `${u.kind || 'diary'}-${u.source_name_normalized}`
|
||||||
|
return (
|
||||||
|
<div key={key} style={{ borderTop: '1px solid var(--border)', padding: '10px 0' }}>
|
||||||
|
<div style={{ fontWeight: 600 }}>{suggestQuery(u.source_name_raw) || u.source_name_raw}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
|
||||||
|
{u.kind === 'recipe_ingredient' ? 'Rezeptzutat' : `${u.count}×`}
|
||||||
|
{u.variant_count > 1 ? ` · ${u.variant_count} Mengen-Varianten` : ''}
|
||||||
|
{u.first_date ? ` · ${u.first_date} – ${u.last_date}` : ''}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={saving === u.source_name_raw}
|
||||||
|
onClick={() => setSearchFor(u)}
|
||||||
|
>
|
||||||
|
Im Katalog suchen
|
||||||
|
</button>
|
||||||
|
{u.kind !== 'recipe_ingredient' && u.matching_recipe_id && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
disabled={saving === u.source_name_raw}
|
||||||
|
onClick={() => applyRecipe(u.source_name_raw, u.matching_recipe_id)}
|
||||||
|
>
|
||||||
|
Als eigenes Rezept auflösen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{u.kind !== 'recipe_ingredient' && recipes.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
disabled={saving === u.source_name_raw}
|
||||||
|
onClick={() => setRecipeFor(u)}
|
||||||
|
>
|
||||||
|
Eigenes Rezept wählen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
<h3 style={{ fontSize: 14, margin: '20px 0 8px' }}>Gelernt ({learned.length})</h3>
|
||||||
|
{learned.map((m) => (
|
||||||
|
<div key={m.id} style={{ display: 'flex', justifyContent: 'space-between', gap: 8, padding: '8px 0', borderTop: '1px solid var(--border)' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500 }}>{m.source_name_raw}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
|
||||||
|
→ {m.food_name_de}{m.bls_code ? ` · ${m.bls_code}` : ''}
|
||||||
|
{m.catalog_kind !== 'official_bls' ? ' (manuell)' : ''}
|
||||||
|
{m.grams_per_unit ? ` · 1 ${m.source_unit || 'Einheit'} = ${m.grams_per_unit} g` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={() => remove(m.id)}>Löschen</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{searchFor && (
|
||||||
|
<FoodSearchModal
|
||||||
|
title={`Suchen: ${searchFor.source_name_raw}`}
|
||||||
|
initialQuery={suggestQuery(searchFor.source_name_raw)}
|
||||||
|
quantityHint={searchFor.sample_quantity_raw || searchFor.source_name_raw}
|
||||||
|
onClose={() => setSearchFor(null)}
|
||||||
|
onSelect={(food, extras) => assign(searchFor.source_name_raw, food.id, extras)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{recipeFor && (
|
||||||
|
<RecipePickModal
|
||||||
|
recipes={recipes}
|
||||||
|
sourceName={recipeFor.source_name_raw}
|
||||||
|
onClose={() => setRecipeFor(null)}
|
||||||
|
onPick={(id) => applyRecipe(recipeFor.source_name_raw, id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<span style={{ display: 'inline-flex', gap: 8 }}>
|
||||||
|
<button type="button" className="btn btn-secondary" style={{ fontSize: 11, padding: '4px 8px' }} onClick={() => setMark('fasting')}>
|
||||||
|
{markType === 'fasting' ? '✓ Fasten' : 'Fasten'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary" style={{ fontSize: 11, padding: '4px 8px' }} onClick={() => setMark('incomplete')}>
|
||||||
|
{markType === 'incomplete' ? '✓ Lücke' : 'Lücke'}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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',
|
id: 'system',
|
||||||
label: 'Basiseinstellungen',
|
label: 'Basiseinstellungen',
|
||||||
|
|
|
||||||
|
|
@ -364,13 +364,15 @@ function SessionMetricsFields({ schema, values, setValues, metrics }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Import Panel ──────────────────────────────────────────────────────────────
|
// ── Import Panel ──────────────────────────────────────────────────────────────
|
||||||
function ImportPanel({ onImported }) {
|
function ImportPanel({ onImported, usage = null }) {
|
||||||
const fileRef = useRef()
|
const fileRef = useRef()
|
||||||
const [status, setStatus] = useState(null)
|
const [status, setStatus] = useState(null)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [dragging, setDragging] = useState(false)
|
const [dragging, setDragging] = useState(false)
|
||||||
|
const atLimit = usage && !usage.allowed
|
||||||
|
|
||||||
const runImport = async (file) => {
|
const runImport = async (file) => {
|
||||||
|
if (atLimit) return
|
||||||
setStatus('loading'); setError(null)
|
setStatus('loading'); setError(null)
|
||||||
try {
|
try {
|
||||||
const result = await api.importActivityCsv(file)
|
const result = await api.importActivityCsv(file)
|
||||||
|
|
@ -382,24 +384,29 @@ function ImportPanel({ onImported }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<div className="card-title">📥 Apple Health Import</div>
|
<div className="card-title badge-container-right">
|
||||||
|
<span>📥 Apple Health Import</span>
|
||||||
|
{usage && <UsageBadge {...usage} />}
|
||||||
|
</div>
|
||||||
<p style={{fontSize:13,color:'var(--text2)',marginBottom:10,lineHeight:1.6}}>
|
<p style={{fontSize:13,color:'var(--text2)',marginBottom:10,lineHeight:1.6}}>
|
||||||
<strong>Health Auto Export App</strong> → Workouts exportieren → CSV → hier hochladen.<br/>
|
<strong>Health Auto Export App</strong> → Workouts exportieren → CSV → hier hochladen.<br/>
|
||||||
Nur die <em>Workouts-…csv</em> Datei wird benötigt (nicht die Detaildateien).
|
Nur die <em>Workouts-…csv</em> Datei wird benötigt (nicht die Detaildateien).
|
||||||
</p>
|
</p>
|
||||||
<input ref={fileRef} type="file" accept=".csv" style={{display:'none'}}
|
<input ref={fileRef} type="file" accept=".csv" style={{display:'none'}}
|
||||||
|
disabled={atLimit}
|
||||||
onChange={e=>{ const f=e.target.files[0]; if(f) runImport(f); e.target.value='' }}/>
|
onChange={e=>{ const f=e.target.files[0]; if(f) runImport(f); e.target.value='' }}/>
|
||||||
<div
|
<div
|
||||||
onDragOver={e=>{e.preventDefault();setDragging(true)}}
|
onDragOver={e=>{ if(!atLimit){ e.preventDefault(); setDragging(true) }}}
|
||||||
onDragLeave={()=>setDragging(false)}
|
onDragLeave={()=>setDragging(false)}
|
||||||
onDrop={e=>{e.preventDefault();setDragging(false);const f=e.dataTransfer.files[0];if(f)runImport(f)}}
|
onDrop={e=>{ e.preventDefault(); setDragging(false); if(atLimit) return; const f=e.dataTransfer.files[0]; if(f) runImport(f) }}
|
||||||
onClick={()=>fileRef.current.click()}
|
onClick={()=>{ if(!atLimit) fileRef.current?.click() }}
|
||||||
|
title={atLimit ? `Limit erreicht (${usage.used}/${usage.limit})` : ''}
|
||||||
style={{border:`2px dashed ${dragging?'var(--accent)':'var(--border2)'}`,borderRadius:10,
|
style={{border:`2px dashed ${dragging?'var(--accent)':'var(--border2)'}`,borderRadius:10,
|
||||||
padding:'20px 16px',textAlign:'center',background:dragging?'var(--accent-light)':'var(--surface2)',
|
padding:'20px 16px',textAlign:'center',background:dragging?'var(--accent-light)':'var(--surface2)',
|
||||||
cursor:'pointer',transition:'all 0.15s'}}>
|
cursor:atLimit?'not-allowed':'pointer',opacity:atLimit?0.65:1,transition:'all 0.15s'}}>
|
||||||
<Upload size={24} style={{color:dragging?'var(--accent)':'var(--text3)',marginBottom:6}}/>
|
<Upload size={24} style={{color:dragging?'var(--accent)':'var(--text3)',marginBottom:6}}/>
|
||||||
<div style={{fontSize:13,color:dragging?'var(--accent-dark)':'var(--text2)'}}>
|
<div style={{fontSize:13,color:dragging?'var(--accent-dark)':'var(--text2)'}}>
|
||||||
{dragging?'Datei loslassen…':'CSV hierher ziehen oder tippen'}
|
{atLimit ? '🔒 Limit erreicht — Import nicht möglich' : dragging ? 'Datei loslassen…' : 'CSV hierher ziehen oder tippen'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{status==='loading' && (
|
{status==='loading' && (
|
||||||
|
|
@ -945,7 +952,12 @@ export default function ActivityPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab==='import' && <ImportPanel onImported={load}/>}
|
{tab==='import' && (
|
||||||
|
<ImportPanel
|
||||||
|
usage={activityUsage}
|
||||||
|
onImported={() => { load(); loadUsage() }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{tab==='categorize' && (
|
{tab==='categorize' && (
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
|
|
|
||||||
57
frontend/src/pages/AdminBlsFoodsPage.jsx
Normal file
57
frontend/src/pages/AdminBlsFoodsPage.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="card">
|
||||||
|
<h1 className="page-title">Lebensmittelkatalog</h1>
|
||||||
|
{error && <p style={{ color: 'var(--danger)' }}>{error}</p>}
|
||||||
|
<div className="form-row">
|
||||||
|
<input className="form-input" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Name oder BLS-Code" />
|
||||||
|
<button type="button" className="btn btn-primary" onClick={search}>Suchen</button>
|
||||||
|
</div>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r.id}
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary btn-full"
|
||||||
|
style={{ marginTop: 6, textAlign: 'left' }}
|
||||||
|
onClick={() => api.adminBlsFoodDetail(r.id).then(setDetail).catch((e) => setError(e.message))}
|
||||||
|
>
|
||||||
|
{r.bls_code ? `${r.bls_code} · ` : ''}{r.name_de}
|
||||||
|
{r.catalog_kind !== 'official_bls' ? ' · manuell' : ''}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{detail && (
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<h3>{detail.bls_code} {detail.name_de}</h3>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text3)' }}>{detail.catalog_kind} · {detail.bls_version || '—'}</p>
|
||||||
|
<table style={{ width: '100%', fontSize: 12 }}>
|
||||||
|
<tbody>
|
||||||
|
{(detail.attributes || []).filter((a) => a.value_num != null || a.is_trace).slice(0, 40).map((a) => (
|
||||||
|
<tr key={a.attr_key}>
|
||||||
|
<td>{a.attr_key}</td>
|
||||||
|
<td>{a.name_de}</td>
|
||||||
|
<td>{a.is_trace ? 'Spuren' : a.value_num} {a.unit || ''}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
148
frontend/src/pages/AdminBlsImportPage.jsx
Normal file
148
frontend/src/pages/AdminBlsImportPage.jsx
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { api } from '../utils/api'
|
||||||
|
|
||||||
|
function summarizeCheck(kind, check) {
|
||||||
|
if (!check) return ''
|
||||||
|
if (kind === 'components') return `${check.attributes ?? 0} Stoffe erkannt`
|
||||||
|
return `${check.foods ?? 0} Lebensmittel, ${check.attribute_columns ?? 0} Stoffspalten`
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForJob(jobId, doneStatuses, onTick) {
|
||||||
|
for (;;) {
|
||||||
|
const job = await api.adminBlsImportJob(jobId)
|
||||||
|
onTick?.(job)
|
||||||
|
if (job.status === 'error') {
|
||||||
|
throw new Error(job.error || 'Import fehlgeschlagen')
|
||||||
|
}
|
||||||
|
if (doneStatuses.includes(job.status)) return job
|
||||||
|
await sleep(1200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileImportBlock({ label, kind, onImported }) {
|
||||||
|
const inputRef = useRef(null)
|
||||||
|
const [file, setFile] = useState(null)
|
||||||
|
const [job, setJob] = useState(null)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [busy, setBusy] = useState(null)
|
||||||
|
|
||||||
|
const checkFile = async (nextFile) => {
|
||||||
|
setError(null)
|
||||||
|
setJob(null)
|
||||||
|
if (!nextFile) return
|
||||||
|
setBusy('check')
|
||||||
|
try {
|
||||||
|
const started = await api.adminBlsImportStart(kind, nextFile)
|
||||||
|
const done = await waitForJob(started.id, ['checked'], setJob)
|
||||||
|
setJob(done)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message)
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyImport = async () => {
|
||||||
|
if (!job?.id || job.status !== 'checked') return
|
||||||
|
setBusy('apply')
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await api.adminBlsImportApply(job.id)
|
||||||
|
const done = await waitForJob(job.id, ['done'], setJob)
|
||||||
|
setJob(done)
|
||||||
|
onImported?.()
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message)
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress = job?.progress
|
||||||
|
const result = job?.result
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-page__field">
|
||||||
|
<label className="settings-page__field-label">{label}</label>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx"
|
||||||
|
disabled={!!busy}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = e.target.files?.[0] || null
|
||||||
|
setFile(next)
|
||||||
|
checkFile(next)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary btn-full"
|
||||||
|
disabled={!!busy}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
>
|
||||||
|
{file ? file.name : 'Datei auswählen'}
|
||||||
|
</button>
|
||||||
|
{busy === 'check' && <p style={{ fontSize: 13, color: 'var(--text2)', margin: 0 }}>Prüfe…</p>}
|
||||||
|
{busy === 'apply' && (
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text2)', margin: 0 }}>
|
||||||
|
Importiere…{progress?.total ? ` ${progress.current}/${progress.total}` : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{error && <p style={{ color: 'var(--danger)', margin: 0 }}>{error}</p>}
|
||||||
|
{job?.status === 'checked' && !error && (
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text2)', margin: 0 }}>
|
||||||
|
Prüfung: {summarizeCheck(kind, job.check)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{job?.status === 'done' && result && (
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text2)', margin: 0 }}>
|
||||||
|
{(result.inserted ?? result.foods_inserted) ?? 0} neu · {(result.updated ?? result.foods_updated) ?? 0} aktualisiert
|
||||||
|
{result.values_written != null ? ` · ${result.values_written} Werte` : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-full"
|
||||||
|
disabled={job?.status !== 'checked' || !!error || !!busy}
|
||||||
|
onClick={applyImport}
|
||||||
|
>
|
||||||
|
{job?.status === 'done' ? 'Importiert' : 'Importieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminBlsImportPage() {
|
||||||
|
const [status, setStatus] = useState(null)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
const load = () => {
|
||||||
|
api.adminBlsStatus().then(setStatus).catch((e) => setError(e.message))
|
||||||
|
}
|
||||||
|
useEffect(() => { load() }, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<h1 className="page-title">BLS 4.0 importieren</h1>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6 }}>
|
||||||
|
Offizielle Dateien von blsdb.de (frei verfügbar, MRI). Codes bleiben erhalten.
|
||||||
|
Datei wählen prüft automatisch. Danach Importieren. Zuerst Components, dann Daten.
|
||||||
|
</p>
|
||||||
|
{status && (
|
||||||
|
<p style={{ fontSize: 13 }}>
|
||||||
|
{status.official_foods} offizielle Lebensmittel · {status.official_attributes} Stoffe ·
|
||||||
|
{status.manual_foods} manuell · {status.source}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{error && <p style={{ color: 'var(--danger)' }}>{error}</p>}
|
||||||
|
<FileImportBlock label="Components XLSX" kind="components" onImported={load} />
|
||||||
|
<FileImportBlock label="Daten XLSX" kind="foods" onImported={load} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -122,6 +122,68 @@ function buildImportRowProcessingSimple(modFields, fm, groupBy, mode, multiRowPo
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Gleiche Logik wie beim Speichern — für Formatprüfung und Create/Update. */
|
||||||
|
function resolveEditorImportRowProcessing({
|
||||||
|
aggregateSleepImport,
|
||||||
|
rowAggUseCustom,
|
||||||
|
rowAggIrregular,
|
||||||
|
rowAggJsonText,
|
||||||
|
rowAggGroupBy,
|
||||||
|
rowAggMode,
|
||||||
|
rowAggMultiRowPolicy,
|
||||||
|
rowAggDedupeIdentical,
|
||||||
|
modFields,
|
||||||
|
fieldMappings,
|
||||||
|
assignedTargets,
|
||||||
|
}) {
|
||||||
|
if (aggregateSleepImport || !rowAggUseCustom) {
|
||||||
|
return { import_row_processing: null, error: null }
|
||||||
|
}
|
||||||
|
if (rowAggIrregular) {
|
||||||
|
try {
|
||||||
|
const import_row_processing = JSON.parse(rowAggJsonText || '{}')
|
||||||
|
if (!import_row_processing || typeof import_row_processing !== 'object') {
|
||||||
|
return { import_row_processing: null, error: 'Zeilenaggregation: ungültiges JSON.' }
|
||||||
|
}
|
||||||
|
const gb = import_row_processing.group_by
|
||||||
|
if (!Array.isArray(gb) || !gb.length) {
|
||||||
|
return {
|
||||||
|
import_row_processing: null,
|
||||||
|
error: 'Zeilenaggregation (JSON): „group_by“ muss eine nicht-leere Liste sein.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { import_row_processing, error: null }
|
||||||
|
} catch {
|
||||||
|
return { import_row_processing: null, error: 'Zeilenaggregation: ungültiges JSON.' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!rowAggGroupBy.length) {
|
||||||
|
return { import_row_processing: null, error: 'Zeilenaggregation: mindestens ein Schlüsselfeld auswählen.' }
|
||||||
|
}
|
||||||
|
if (!rowAggMode) {
|
||||||
|
return { import_row_processing: null, error: 'Zeilenaggregation: eine Funktion wählen (Summe, Mittelwert, …).' }
|
||||||
|
}
|
||||||
|
for (const g of rowAggGroupBy) {
|
||||||
|
if (!assignedTargets.has(g)) {
|
||||||
|
return {
|
||||||
|
import_row_processing: null,
|
||||||
|
error: `Zeilenaggregation: Schlüsselfeld „${g}“ muss einer CSV-Spalte zugeordnet sein.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
import_row_processing: buildImportRowProcessingSimple(
|
||||||
|
modFields,
|
||||||
|
fieldMappings,
|
||||||
|
rowAggGroupBy,
|
||||||
|
rowAggMode,
|
||||||
|
rowAggMultiRowPolicy,
|
||||||
|
rowAggDedupeIdentical,
|
||||||
|
),
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Erlaubt Eingaben wie 1,03 oder 1.03 während des Tippens; Finale normalisiert bei Blur/Speichern. */
|
/** Erlaubt Eingaben wie 1,03 oder 1.03 während des Tippens; Finale normalisiert bei Blur/Speichern. */
|
||||||
function normalizeDecimalInputString(raw) {
|
function normalizeDecimalInputString(raw) {
|
||||||
let s = String(raw).trim().replace(/\s/g, '')
|
let s = String(raw).trim().replace(/\s/g, '')
|
||||||
|
|
@ -621,11 +683,28 @@ export default function AdminCsvTemplateEditorPage() {
|
||||||
}
|
}
|
||||||
setValidating(true)
|
setValidating(true)
|
||||||
try {
|
try {
|
||||||
|
const { import_row_processing, error: irpError } = resolveEditorImportRowProcessing({
|
||||||
|
aggregateSleepImport,
|
||||||
|
rowAggUseCustom,
|
||||||
|
rowAggIrregular,
|
||||||
|
rowAggJsonText,
|
||||||
|
rowAggGroupBy,
|
||||||
|
rowAggMode,
|
||||||
|
rowAggMultiRowPolicy,
|
||||||
|
rowAggDedupeIdentical,
|
||||||
|
modFields: modMeta?.fields,
|
||||||
|
fieldMappings,
|
||||||
|
assignedTargets,
|
||||||
|
})
|
||||||
|
if (irpError) {
|
||||||
|
setError(irpError)
|
||||||
|
return
|
||||||
|
}
|
||||||
const r = await api.adminValidateCsvTemplate({
|
const r = await api.adminValidateCsvTemplate({
|
||||||
module,
|
module,
|
||||||
field_mappings: fieldMappings,
|
field_mappings: fieldMappings,
|
||||||
type_conversions: tc,
|
type_conversions: tc,
|
||||||
import_row_processing: null,
|
import_row_processing,
|
||||||
column_signature: columnSignature.length ? columnSignature : null,
|
column_signature: columnSignature.length ? columnSignature : null,
|
||||||
})
|
})
|
||||||
setValidationReport(r)
|
setValidationReport(r)
|
||||||
|
|
@ -692,44 +771,25 @@ export default function AdminCsvTemplateEditorPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
let import_row_processing = null
|
let import_row_processing = null
|
||||||
if (!aggregateSleepImport && rowAggUseCustom) {
|
{
|
||||||
if (rowAggIrregular) {
|
const resolved = resolveEditorImportRowProcessing({
|
||||||
try {
|
aggregateSleepImport,
|
||||||
import_row_processing = JSON.parse(rowAggJsonText || '{}')
|
rowAggUseCustom,
|
||||||
if (!import_row_processing || typeof import_row_processing !== 'object') throw new Error('bad')
|
rowAggIrregular,
|
||||||
} catch {
|
rowAggJsonText,
|
||||||
setError('Zeilenaggregation: ungültiges JSON.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const gb = import_row_processing.group_by
|
|
||||||
if (!Array.isArray(gb) || !gb.length) {
|
|
||||||
setError('Zeilenaggregation (JSON): „group_by“ muss eine nicht-leere Liste sein.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (!rowAggGroupBy.length) {
|
|
||||||
setError('Zeilenaggregation: mindestens ein Schlüsselfeld auswählen.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!rowAggMode) {
|
|
||||||
setError('Zeilenaggregation: eine Funktion wählen (Summe, Mittelwert, …).')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for (const g of rowAggGroupBy) {
|
|
||||||
if (!assignedTargets.has(g)) {
|
|
||||||
setError(`Zeilenaggregation: Schlüsselfeld „${g}“ muss einer CSV-Spalte zugeordnet sein.`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
import_row_processing = buildImportRowProcessingSimple(
|
|
||||||
modMeta?.fields,
|
|
||||||
fieldMappings,
|
|
||||||
rowAggGroupBy,
|
rowAggGroupBy,
|
||||||
rowAggMode,
|
rowAggMode,
|
||||||
rowAggMultiRowPolicy,
|
rowAggMultiRowPolicy,
|
||||||
rowAggDedupeIdentical,
|
rowAggDedupeIdentical,
|
||||||
)
|
modFields: modMeta?.fields,
|
||||||
|
fieldMappings,
|
||||||
|
assignedTargets,
|
||||||
|
})
|
||||||
|
if (resolved.error) {
|
||||||
|
setError(resolved.error)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
import_row_processing = resolved.import_row_processing
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
|
|
@ -1494,12 +1554,13 @@ export default function AdminCsvTemplateEditorPage() {
|
||||||
{validationReport.valid ? <span style={{ color: 'var(--accent)' }}>— speicherfähig</span> : <span style={{ color: 'var(--danger)' }}>— Fehler beheben</span>}
|
{validationReport.valid ? <span style={{ color: 'var(--accent)' }}>— speicherfähig</span> : <span style={{ color: 'var(--danger)' }}>— Fehler beheben</span>}
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 10 }}>
|
<p style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 10 }}>
|
||||||
Ohne Zeilenaggregations-JSON; vollständige Prüfung inkl. Aggregation beim Speichern. Warnungen blockieren nicht.
|
Inkl. Zeilenaggregation (wie beim Speichern). Warnungen blockieren nicht.
|
||||||
</p>
|
</p>
|
||||||
{validationReport.errors?.length ? (
|
{validationReport.errors?.length ? (
|
||||||
<ul style={{ margin: '0 0 12px 1rem', color: 'var(--danger)', fontSize: 14 }}>
|
<ul style={{ margin: '0 0 12px 1rem', color: 'var(--danger)', fontSize: 14 }}>
|
||||||
{validationReport.errors.map((e, i) => (
|
{validationReport.errors.map((e, i) => (
|
||||||
<li key={`e-${i}`}>
|
<li key={`e-${i}`}>
|
||||||
|
{e.code ? <code style={{ fontSize: 11, marginRight: 6 }}>{e.code}</code> : null}
|
||||||
{e.message}
|
{e.message}
|
||||||
{e.hint ? <span style={{ display: 'block', fontSize: 12, color: 'var(--text2)', marginTop: 4 }}>{e.hint}</span> : null}
|
{e.hint ? <span style={{ display: 'block', fontSize: 12, color: 'var(--text2)', marginTop: 4 }}>{e.hint}</span> : null}
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -1510,6 +1571,7 @@ export default function AdminCsvTemplateEditorPage() {
|
||||||
<ul style={{ margin: '0 0 0 1rem', color: 'var(--text2)', fontSize: 13 }}>
|
<ul style={{ margin: '0 0 0 1rem', color: 'var(--text2)', fontSize: 13 }}>
|
||||||
{validationReport.warnings.map((w, i) => (
|
{validationReport.warnings.map((w, i) => (
|
||||||
<li key={`w-${i}`}>
|
<li key={`w-${i}`}>
|
||||||
|
{w.code ? <code style={{ fontSize: 11, marginRight: 6 }}>{w.code}</code> : null}
|
||||||
{w.message}
|
{w.message}
|
||||||
{w.hint ? <span style={{ display: 'block', fontSize: 12, color: 'var(--text3)', marginTop: 4 }}>{w.hint}</span> : null}
|
{w.hint ? <span style={{ display: 'block', fontSize: 12, color: 'var(--text3)', marginTop: 4 }}>{w.hint}</span> : null}
|
||||||
</li>
|
</li>
|
||||||
|
|
|
||||||
44
frontend/src/pages/AdminFoodAttributesPage.jsx
Normal file
44
frontend/src/pages/AdminFoodAttributesPage.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="card">
|
||||||
|
<h1 className="page-title">Stoffe & Attribute</h1>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text2)' }}>
|
||||||
|
Offizielle BLS-Codes kommen aus dem Import. Erweiterungen (Histamin, Gluten, …) hier anlegen — ohne Migration.
|
||||||
|
</p>
|
||||||
|
{error && <p style={{ color: 'var(--danger)' }}>{error}</p>}
|
||||||
|
<div className="form-row">
|
||||||
|
<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="Einheit" value={form.unit} onChange={(e) => setForm({ ...form, unit: e.target.value })} />
|
||||||
|
<select className="form-input" value={form.data_type} onChange={(e) => setForm({ ...form, data_type: e.target.value })}>
|
||||||
|
<option value="num_per_100g">Zahl / 100g</option>
|
||||||
|
<option value="boolean">Ja/Nein</option>
|
||||||
|
<option value="text">Text</option>
|
||||||
|
<option value="enum">Auswahl</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={async () => {
|
||||||
|
try {
|
||||||
|
await api.adminCreateBlsAttribute(form)
|
||||||
|
setForm({ attr_key: '', name_de: '', unit: '', data_type: 'num_per_100g' })
|
||||||
|
load()
|
||||||
|
} catch (e) { setError(e.message) }
|
||||||
|
}}>Anlegen</button>
|
||||||
|
</div>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<div key={r.id} style={{ fontSize: 13, padding: '6px 0', borderTop: '1px solid var(--border)' }}>
|
||||||
|
<strong>{r.attr_key}</strong> · {r.name_de} · {r.data_type} · {r.origin}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
68
frontend/src/pages/AdminFoodMappingsPage.jsx
Normal file
68
frontend/src/pages/AdminFoodMappingsPage.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="card">
|
||||||
|
<h1 className="page-title">FDDB→BLS Mappings</h1>
|
||||||
|
{error && <p style={{ color: 'var(--danger)' }}>{error}</p>}
|
||||||
|
{coverage && (
|
||||||
|
<p style={{ fontSize: 13 }}>
|
||||||
|
{coverage.mapped_items}/{coverage.total_items} Zeilen gemappt ·
|
||||||
|
{coverage.unmapped_names} ungemappte Namen
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="form-row">
|
||||||
|
<input className="form-input" placeholder="FDDB-Bezeichner" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<input className="form-input" placeholder="Lebensmittel suchen" value={foodQ} onChange={async (e) => {
|
||||||
|
setFoodQ(e.target.value)
|
||||||
|
if (e.target.value.length > 1) setHits(await api.searchBlsFoods(e.target.value))
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
{hits.map((h) => (
|
||||||
|
<button key={h.id} type="button" className="btn btn-secondary" style={{ margin: 4 }} onClick={() => { setFoodId(h.id); setFoodQ(h.name_de) }}>
|
||||||
|
{h.bls_code} {h.name_de}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={!name || !foodId}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await api.adminCreateFoodMapping({ source_name: name, food_id: foodId })
|
||||||
|
setName(''); setFoodId(''); setFoodQ(''); load()
|
||||||
|
} catch (e) { setError(e.message) }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Globales Mapping anlegen
|
||||||
|
</button>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<div key={r.id} style={{ display: 'flex', justifyContent: 'space-between', padding: '8px 0', borderTop: '1px solid var(--border)' }}>
|
||||||
|
<div>
|
||||||
|
{r.source_name_raw} → {r.bls_code} {r.food_name_de}
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text3)' }}>{r.profile_id ? 'user' : 'global'}</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={async () => { await api.adminDeleteFoodMapping(r.id); load() }}>Löschen</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { Upload, CheckCircle, TrendingUp, Info } from 'lucide-react'
|
import { Upload, CheckCircle, TrendingUp, Info } from 'lucide-react'
|
||||||
|
import UsageBadge from '../components/UsageBadge'
|
||||||
|
import NutritionFoodMap, { DayMarkButtons } from '../components/NutritionFoodMap'
|
||||||
import {
|
import {
|
||||||
LineChart, Line, BarChart, Bar, XAxis, YAxis, Tooltip,
|
LineChart, Line, BarChart, Bar, XAxis, YAxis, Tooltip,
|
||||||
ResponsiveContainer, CartesianGrid, Legend, ReferenceLine, ScatterChart, Scatter
|
ResponsiveContainer, CartesianGrid, Legend, ReferenceLine, ScatterChart, Scatter
|
||||||
|
|
@ -193,6 +195,49 @@ function EntryForm({ onSaved }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Data Tab (Editable Entry List) ───────────────────────────────────────────
|
// ── Data Tab (Editable Entry List) ───────────────────────────────────────────
|
||||||
|
function DayItems({ date }) {
|
||||||
|
const [items, setItems] = useState(null)
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
const toggle = async () => {
|
||||||
|
if (open) {
|
||||||
|
setOpen(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!items) {
|
||||||
|
try {
|
||||||
|
setItems(await nutritionApi.listNutritionItems(date))
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} onClick={toggle}>
|
||||||
|
{open ? 'Lebensmittel ausblenden' : 'Lebensmittel zeigen'}
|
||||||
|
</button>
|
||||||
|
{error && <p style={{ color: 'var(--danger)', fontSize: 12, margin: '6px 0 0' }}>{error}</p>}
|
||||||
|
{open && Array.isArray(items) && (
|
||||||
|
<ul style={{ margin: '8px 0 0', paddingLeft: 18, fontSize: 13, color: 'var(--text1)' }}>
|
||||||
|
{items.length === 0 && <li style={{ color: 'var(--text3)' }}>Keine Zeilen für diesen Tag</li>}
|
||||||
|
{items.map((it) => (
|
||||||
|
<li key={it.id}>
|
||||||
|
{it.source_name_raw}
|
||||||
|
{it.quantity_raw ? ` · ${it.quantity_raw}` : ''}
|
||||||
|
{it.food_name_de ? ` → ${it.food_name_de}` : ' · noch nicht zugeordnet'}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function DataTab({ entries, onUpdate }) {
|
function DataTab({ entries, onUpdate }) {
|
||||||
const [editId, setEditId] = useState(null)
|
const [editId, setEditId] = useState(null)
|
||||||
const [editValues, setEditValues] = useState({})
|
const [editValues, setEditValues] = useState({})
|
||||||
|
|
@ -323,9 +368,19 @@ function DataTab({ entries, onUpdate }) {
|
||||||
</div>
|
</div>
|
||||||
{e.source && (
|
{e.source && (
|
||||||
<div style={{fontSize:10, color:'var(--text3)', marginTop:4}}>
|
<div style={{fontSize:10, color:'var(--text3)', marginTop:4}}>
|
||||||
Quelle: {e.source}
|
Quelle: {e.source}{e.macro_origin ? ` · Makros: ${e.macro_origin}` : ''}
|
||||||
|
{e.mark_type ? ` · ${e.mark_type === 'fasting' ? 'Fastentag' : 'unvollständig'}` : ''}
|
||||||
|
{e.item_count ? ` · ${e.item_count} Lebensmittel` : ''}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{e.item_count > 0 ? (
|
||||||
|
<DayItems date={e.date} />
|
||||||
|
) : (
|
||||||
|
<div style={{fontSize:12, color:'var(--text3)', marginTop:6}}>Keine einzelnen Lebensmittelzeilen</div>
|
||||||
|
)}
|
||||||
|
<div style={{marginTop:8}}>
|
||||||
|
<DayMarkButtons date={e.date} markType={e.mark_type} onChanged={onUpdate} />
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
@ -433,20 +488,25 @@ function ImportHistory() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Import Panel ──────────────────────────────────────────────────────────────
|
// ── Import Panel ──────────────────────────────────────────────────────────────
|
||||||
function ImportPanel({ onImported }) {
|
function ImportPanel({ onImported, usage = null }) {
|
||||||
const fileRef = useRef()
|
const fileRef = useRef()
|
||||||
const [status, setStatus] = useState(null)
|
const [status, setStatus] = useState(null)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [dragging,setDragging]= useState(false)
|
const [dragging,setDragging]= useState(false)
|
||||||
const [tab, setTab] = useState('file') // 'file' | 'paste'
|
const [tab, setTab] = useState('file') // 'file' | 'paste'
|
||||||
const [pasteText, setPasteText] = useState('')
|
const [pasteText, setPasteText] = useState('')
|
||||||
|
const [overwrite, setOverwrite] = useState(false)
|
||||||
|
const [conflicts, setConflicts] = useState([])
|
||||||
|
const atLimit = usage && !usage.allowed
|
||||||
|
|
||||||
const runImport = async (file) => {
|
const runImport = async (file) => {
|
||||||
setStatus('loading'); setError(null)
|
if (atLimit) return
|
||||||
|
setStatus('loading'); setError(null); setConflicts([])
|
||||||
try {
|
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))
|
if (result.days_imported === undefined) throw new Error(JSON.stringify(result))
|
||||||
setStatus(result)
|
setStatus(result)
|
||||||
|
setConflicts(result.conflicts || [])
|
||||||
onImported()
|
onImported()
|
||||||
} catch(err) {
|
} catch(err) {
|
||||||
setError('Import fehlgeschlagen: ' + err.message)
|
setError('Import fehlgeschlagen: ' + err.message)
|
||||||
|
|
@ -468,7 +528,7 @@ function ImportPanel({ onImported }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePasteImport = async () => {
|
const handlePasteImport = async () => {
|
||||||
if (!pasteText.trim()) return
|
if (!pasteText.trim() || atLimit) return
|
||||||
const blob = new Blob([pasteText], { type: 'text/csv' })
|
const blob = new Blob([pasteText], { type: 'text/csv' })
|
||||||
const file = new File([blob], 'paste.csv', { type: 'text/csv' })
|
const file = new File([blob], 'paste.csv', { type: 'text/csv' })
|
||||||
await runImport(file)
|
await runImport(file)
|
||||||
|
|
@ -476,10 +536,18 @@ function ImportPanel({ onImported }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<div className="card-title">📥 FDDB CSV Import</div>
|
<div className="card-title badge-container-right">
|
||||||
|
<span>📥 FDDB CSV Import</span>
|
||||||
|
{usage && <UsageBadge {...usage} />}
|
||||||
|
</div>
|
||||||
<p style={{fontSize:13,color:'var(--text2)',marginBottom:10,lineHeight:1.6}}>
|
<p style={{fontSize:13,color:'var(--text2)',marginBottom:10,lineHeight:1.6}}>
|
||||||
In FDDB: <strong>Mein Tagebuch → Exportieren → CSV</strong> — dann hier importieren.
|
In FDDB: <strong>Mein Tagebuch → Exportieren → CSV</strong> — dann hier importieren.
|
||||||
|
Zeilen (Name, Menge, Uhrzeit) werden gespeichert, wenn die CSV sie enthält.
|
||||||
</p>
|
</p>
|
||||||
|
<label style={{display:'flex',alignItems:'center',gap:8,fontSize:13,marginBottom:10}}>
|
||||||
|
<input type="checkbox" checked={overwrite} onChange={(e)=>setOverwrite(e.target.checked)} />
|
||||||
|
Vorhandene Tagesmakros überschreiben (Initialimport)
|
||||||
|
</label>
|
||||||
|
|
||||||
{/* Tab switcher */}
|
{/* Tab switcher */}
|
||||||
<div style={{display:'flex',gap:6,marginBottom:12}}>
|
<div style={{display:'flex',gap:6,marginBottom:12}}>
|
||||||
|
|
@ -498,23 +566,26 @@ function ImportPanel({ onImported }) {
|
||||||
<>
|
<>
|
||||||
{/* Drag & Drop Zone */}
|
{/* Drag & Drop Zone */}
|
||||||
<div
|
<div
|
||||||
onDragOver={e=>{e.preventDefault();setDragging(true)}}
|
onDragOver={e=>{ if(!atLimit){ e.preventDefault(); setDragging(true) }}}
|
||||||
onDragLeave={()=>setDragging(false)}
|
onDragLeave={()=>setDragging(false)}
|
||||||
onDrop={handleDrop}
|
onDrop={e=>{ e.preventDefault(); setDragging(false); if(atLimit) return; handleDrop(e) }}
|
||||||
onClick={()=>fileRef.current.click()}
|
onClick={()=>{ if(!atLimit) fileRef.current?.click() }}
|
||||||
|
title={atLimit ? `Limit erreicht (${usage.used}/${usage.limit})` : ''}
|
||||||
style={{
|
style={{
|
||||||
border:`2px dashed ${dragging?'var(--accent)':'var(--border2)'}`,
|
border:`2px dashed ${dragging?'var(--accent)':'var(--border2)'}`,
|
||||||
borderRadius:10, padding:'24px 16px', textAlign:'center',
|
borderRadius:10, padding:'24px 16px', textAlign:'center',
|
||||||
background: dragging?'var(--accent-light)':'var(--surface2)',
|
background: dragging?'var(--accent-light)':'var(--surface2)',
|
||||||
cursor:'pointer', transition:'all 0.15s',
|
cursor:atLimit?'not-allowed':'pointer', opacity:atLimit?0.65:1, transition:'all 0.15s',
|
||||||
}}>
|
}}>
|
||||||
<Upload size={28} style={{color:dragging?'var(--accent)':'var(--text3)',marginBottom:8}}/>
|
<Upload size={28} style={{color:dragging?'var(--accent)':'var(--text3)',marginBottom:8}}/>
|
||||||
<div style={{fontSize:14,fontWeight:500,color:dragging?'var(--accent-dark)':'var(--text2)'}}>
|
<div style={{fontSize:14,fontWeight:500,color:dragging?'var(--accent-dark)':'var(--text2)'}}>
|
||||||
{dragging ? 'Datei loslassen…' : 'CSV hierher ziehen oder tippen zum Auswählen'}
|
{atLimit
|
||||||
|
? '🔒 Limit erreicht — Import nicht möglich'
|
||||||
|
: dragging ? 'Datei loslassen…' : 'CSV hierher ziehen oder tippen zum Auswählen'}
|
||||||
</div>
|
</div>
|
||||||
<div style={{fontSize:11,color:'var(--text3)',marginTop:4}}>.csv Dateien</div>
|
<div style={{fontSize:11,color:'var(--text3)',marginTop:4}}>.csv Dateien</div>
|
||||||
</div>
|
</div>
|
||||||
<input ref={fileRef} type="file" accept=".csv" style={{display:'none'}} onChange={handleFile}/>
|
<input ref={fileRef} type="file" accept=".csv" style={{display:'none'}} disabled={atLimit} onChange={handleFile}/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -523,14 +594,20 @@ function ImportPanel({ onImported }) {
|
||||||
<textarea
|
<textarea
|
||||||
style={{width:'100%',minHeight:120,padding:10,fontFamily:'monospace',fontSize:11,
|
style={{width:'100%',minHeight:120,padding:10,fontFamily:'monospace',fontSize:11,
|
||||||
background:'var(--surface2)',border:'1.5px solid var(--border2)',borderRadius:8,
|
background:'var(--surface2)',border:'1.5px solid var(--border2)',borderRadius:8,
|
||||||
color:'var(--text1)',resize:'vertical',boxSizing:'border-box'}}
|
color:'var(--text1)',resize:'vertical',boxSizing:'border-box',
|
||||||
|
opacity:atLimit?0.65:1}}
|
||||||
placeholder="datum_tag_monat_jahr_stunde_minute;bezeichnung; 13.03.2026 21:54;50 g Hähnchen;..."
|
placeholder="datum_tag_monat_jahr_stunde_minute;bezeichnung; 13.03.2026 21:54;50 g Hähnchen;..."
|
||||||
value={pasteText}
|
value={pasteText}
|
||||||
onChange={e=>setPasteText(e.target.value)}
|
onChange={e=>setPasteText(e.target.value)}
|
||||||
|
disabled={atLimit}
|
||||||
/>
|
/>
|
||||||
<button className="btn btn-primary btn-full" style={{marginTop:8}}
|
<button className="btn btn-primary btn-full" style={{marginTop:8}}
|
||||||
onClick={handlePasteImport} disabled={status==='loading'||!pasteText.trim()}>
|
onClick={handlePasteImport}
|
||||||
{status==='loading'
|
disabled={status==='loading'||!pasteText.trim()||atLimit}
|
||||||
|
title={atLimit ? `Limit erreicht (${usage.used}/${usage.limit})` : ''}>
|
||||||
|
{atLimit
|
||||||
|
? '🔒 Limit erreicht'
|
||||||
|
: status==='loading'
|
||||||
? <><div className="spinner" style={{width:14,height:14}}/> Importiere…</>
|
? <><div className="spinner" style={{width:14,height:14}}/> Importiere…</>
|
||||||
: <><Upload size={15}/> CSV-Text importieren</>}
|
: <><Upload size={15}/> CSV-Text importieren</>}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -552,7 +629,7 @@ function ImportPanel({ onImported }) {
|
||||||
<div style={{display:'flex',alignItems:'center',gap:6,marginBottom:4}}>
|
<div style={{display:'flex',alignItems:'center',gap:6,marginBottom:4}}>
|
||||||
<CheckCircle size={15}/><strong>Import erfolgreich</strong>
|
<CheckCircle size={15}/><strong>Import erfolgreich</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>{status.days_imported} Tage importiert · {status.rows_parsed} Einträge verarbeitet</div>
|
<div>{status.days_imported} Tage · {status.rows_parsed} Zeilen{status.items_written ? ` · ${status.items_written} Items` : ''}</div>
|
||||||
{status.date_range?.from && (
|
{status.date_range?.from && (
|
||||||
<div style={{fontSize:11,marginTop:2}}>
|
<div style={{fontSize:11,marginTop:2}}>
|
||||||
{dayjs(status.date_range.from).format('DD.MM.YYYY')} – {dayjs(status.date_range.to).format('DD.MM.YYYY')}
|
{dayjs(status.date_range.from).format('DD.MM.YYYY')} – {dayjs(status.date_range.to).format('DD.MM.YYYY')}
|
||||||
|
|
@ -560,6 +637,45 @@ function ImportPanel({ onImported }) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{conflicts.length > 0 && (
|
||||||
|
<ConflictDialog conflicts={conflicts} onDone={() => { setConflicts([]); onImported() }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div style={{marginTop:12,padding:12,border:'1px solid var(--border)',borderRadius:8}}>
|
||||||
|
<strong>Abweichende Tagesmakros</strong>
|
||||||
|
{conflicts.map((c) => (
|
||||||
|
<div key={c.date} style={{marginTop:10,fontSize:12}}>
|
||||||
|
<div>{c.date}</div>
|
||||||
|
<div>Ist {c.existing?.kcal} · FDDB {c.fddb?.kcal} · Katalog {c.catalog?.kcal} kcal</div>
|
||||||
|
{['existing','fddb','catalog'].map((k) => (
|
||||||
|
<label key={k} style={{marginRight:10}}>
|
||||||
|
<input type="radio" name={`c-${c.date}`} checked={(choices[c.date]||'existing')===k} onChange={()=>setC(c.date,k)} />
|
||||||
|
{k === 'existing' ? 'behalten' : k === 'fddb' ? 'FDDB' : 'Katalog'}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" className="btn btn-primary" style={{marginTop:10}} disabled={busy} onClick={save}>Übernehmen</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -781,30 +897,49 @@ export default function NutritionPage() {
|
||||||
const [loading, setLoad] = useState(true)
|
const [loading, setLoad] = useState(true)
|
||||||
const [hasData, setHasData]= useState(false)
|
const [hasData, setHasData]= useState(false)
|
||||||
const [importHistoryKey, setImportHistoryKey] = useState(Date.now()) // BUG-004 fix
|
const [importHistoryKey, setImportHistoryKey] = useState(Date.now()) // BUG-004 fix
|
||||||
|
const [nutritionUsage, setNutritionUsage] = useState(null)
|
||||||
|
const [unmappedCount, setUnmappedCount] = useState(0)
|
||||||
|
|
||||||
|
const loadUsage = () => {
|
||||||
|
nutritionApi.getFeatureUsage().then(features => {
|
||||||
|
const nutritionFeature = features.find(f => f.feature_id === 'nutrition_entries')
|
||||||
|
setNutritionUsage(nutritionFeature ?? null)
|
||||||
|
}).catch(err => console.error('Failed to load usage:', err))
|
||||||
|
}
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoad(true)
|
setLoad(true)
|
||||||
try {
|
try {
|
||||||
const [corr, wkly, ent, prof] = await Promise.all([
|
const [corr, wkly, ent, prof, unmapped] = await Promise.all([
|
||||||
nutritionApi.nutritionCorrelations(),
|
nutritionApi.nutritionCorrelations(),
|
||||||
nutritionApi.nutritionWeekly(16),
|
nutritionApi.nutritionWeekly(16),
|
||||||
nutritionApi.listNutrition(365), // BUG-002 fix: load raw entries
|
nutritionApi.listNutrition(365), // BUG-002 fix: load raw entries
|
||||||
nutritionApi.getActiveProfile(),
|
nutritionApi.getActiveProfile(),
|
||||||
|
nutritionApi.listUnmappedFoods().catch(() => []),
|
||||||
])
|
])
|
||||||
setCorr(Array.isArray(corr)?corr:[])
|
setCorr(Array.isArray(corr)?corr:[])
|
||||||
setWeekly(Array.isArray(wkly)?wkly:[])
|
setWeekly(Array.isArray(wkly)?wkly:[])
|
||||||
setEntries(Array.isArray(ent)?ent:[]) // BUG-002 fix
|
setEntries(Array.isArray(ent)?ent:[]) // BUG-002 fix
|
||||||
setProf(prof)
|
setProf(prof)
|
||||||
|
setUnmappedCount(Array.isArray(unmapped) ? unmapped.length : 0)
|
||||||
setHasData(Array.isArray(corr) && corr.some(d=>d.kcal))
|
setHasData(Array.isArray(corr) && corr.some(d=>d.kcal))
|
||||||
} catch(e) { console.error('load error:', e) }
|
} catch(e) { console.error('load error:', e) }
|
||||||
finally { setLoad(false) }
|
finally { setLoad(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { load() }, [])
|
useEffect(() => { load(); loadUsage() }, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="capture-page">
|
<div className="capture-page">
|
||||||
<h1 className="page-title">Ernährung</h1>
|
<h1 className="page-title">Ernährung</h1>
|
||||||
|
{unmappedCount > 0 && (
|
||||||
|
<div className="card" style={{ marginBottom: 12, padding: 12, fontSize: 13 }}>
|
||||||
|
{unmappedCount} Lebensmittel noch ohne Katalog-Zuordnung.{' '}
|
||||||
|
<button type="button" className="btn btn-secondary" style={{ marginLeft: 8 }} onClick={() => setInputTab('map')}>
|
||||||
|
Jetzt zuordnen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Input Method Tabs */}
|
{/* Input Method Tabs */}
|
||||||
<div className="tabs section-gap" style={{marginBottom:0}}>
|
<div className="tabs section-gap" style={{marginBottom:0}}>
|
||||||
|
|
@ -814,6 +949,9 @@ export default function NutritionPage() {
|
||||||
<button className={'tab'+(inputTab==='import'?' active':'')} onClick={()=>setInputTab('import')}>
|
<button className={'tab'+(inputTab==='import'?' active':'')} onClick={()=>setInputTab('import')}>
|
||||||
📥 Import
|
📥 Import
|
||||||
</button>
|
</button>
|
||||||
|
<button className={'tab'+(inputTab==='map'?' active':'')} onClick={()=>setInputTab('map')}>
|
||||||
|
Zuordnen{unmappedCount ? ` (${unmappedCount})` : ''}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Entry Form */}
|
{/* Entry Form */}
|
||||||
|
|
@ -822,11 +960,16 @@ export default function NutritionPage() {
|
||||||
{/* Import Panel + History */}
|
{/* Import Panel + History */}
|
||||||
{inputTab==='import' && (
|
{inputTab==='import' && (
|
||||||
<>
|
<>
|
||||||
<ImportPanel onImported={() => { load(); setImportHistoryKey(Date.now()) }}/>
|
<ImportPanel
|
||||||
|
usage={nutritionUsage}
|
||||||
|
onImported={() => { load(); loadUsage(); setImportHistoryKey(Date.now()) }}
|
||||||
|
/>
|
||||||
<ImportHistory key={importHistoryKey}/>
|
<ImportHistory key={importHistoryKey}/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{inputTab==='map' && <NutritionFoodMap onChanged={load} />}
|
||||||
|
|
||||||
{loading && <div className="empty-state"><div className="spinner"/></div>}
|
{loading && <div className="empty-state"><div className="spinner"/></div>}
|
||||||
|
|
||||||
{!loading && !hasData && (
|
{!loading && !hasData && (
|
||||||
|
|
|
||||||
|
|
@ -458,6 +458,33 @@ export default function SettingsPage() {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="card section-gap">
|
||||||
|
<div className="card-title">Ernährungs-Import</div>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 12, lineHeight: 1.6 }}>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={activeProfile?.nutrition_import_conflict_policy || 'prompt'}
|
||||||
|
onChange={async (e) => {
|
||||||
|
try {
|
||||||
|
await api.updateActiveProfile({ nutrition_import_conflict_policy: e.target.value })
|
||||||
|
await refreshProfiles()
|
||||||
|
setSaved(true)
|
||||||
|
setTimeout(() => setSaved(false), 2000)
|
||||||
|
} catch (err) {
|
||||||
|
setProfileErr(err.message)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="prompt">Bei Abweichung nachfragen</option>
|
||||||
|
<option value="overwrite_catalog">Immer Katalog-/BLS-Summe</option>
|
||||||
|
<option value="overwrite_fddb">Immer FDDB-Rohsumme</option>
|
||||||
|
<option value="keep_existing">Bestehende Makros behalten</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Auth actions */}
|
{/* Auth actions */}
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<div className="card-title">🔐 Konto</div>
|
<div className="card-title">🔐 Konto</div>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'
|
||||||
import { ArrowLeft, FileSpreadsheet, Loader2, Upload } from 'lucide-react'
|
import { ArrowLeft, FileSpreadsheet, Loader2, Upload } from 'lucide-react'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
import { csvPreviewTdStyle } from '../utils/csvPreviewCells'
|
import { csvPreviewTdStyle } from '../utils/csvPreviewCells'
|
||||||
|
import CsvImportErrorDetails from '../components/CsvImportErrorDetails'
|
||||||
|
|
||||||
/** Ziele, die der Universal-Executor bereits schreiben kann (ohne manuelle Modul-Wahl). */
|
/** Ziele, die der Universal-Executor bereits schreiben kann (ohne manuelle Modul-Wahl). */
|
||||||
const EXECUTOR_READY = new Set([
|
const EXECUTOR_READY = new Set([
|
||||||
|
|
@ -200,10 +201,14 @@ export default function UniversalCsvImportPage() {
|
||||||
setLastImport(res)
|
setLastImport(res)
|
||||||
const st = res.stats || {}
|
const st = res.stats || {}
|
||||||
const modLabel = MODULE_LABEL[res.module] || res.module || ''
|
const modLabel = MODULE_LABEL[res.module] || res.module || ''
|
||||||
|
const extra = []
|
||||||
|
if (st.items_written) extra.push(`${st.items_written} Lebensmittelzeilen`)
|
||||||
|
if (st.unmapped_names) extra.push(`${st.unmapped_names} noch ohne Zuordnung — Ernährung → Zuordnen`)
|
||||||
setSuccess(
|
setSuccess(
|
||||||
(modLabel ? `${modLabel}: ` : '') +
|
(modLabel ? `${modLabel}: ` : '') +
|
||||||
`Import fertig — ${st.imported ?? 0} neu, ${st.updated ?? 0} aktualisiert, ` +
|
`Import fertig — ${st.imported ?? 0} neu, ${st.updated ?? 0} aktualisiert, ` +
|
||||||
`${st.skipped ?? 0} übersprungen, ${st.errors ?? 0} Zeilenfehler.`,
|
`${st.skipped ?? 0} übersprungen, ${st.errors ?? 0} Zeilenfehler.` +
|
||||||
|
(extra.length ? ` ${extra.join(' · ')}.` : ''),
|
||||||
)
|
)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message || 'Import fehlgeschlagen')
|
setError(e.message || 'Import fehlgeschlagen')
|
||||||
|
|
@ -288,29 +293,11 @@ export default function UniversalCsvImportPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{lastImport?.error_details?.length > 0 && (
|
{lastImport?.error_details?.length > 0 && (
|
||||||
<details
|
<CsvImportErrorDetails
|
||||||
className="card"
|
errors={lastImport.error_details}
|
||||||
style={{ marginBottom: 16, padding: 16, cursor: 'pointer' }}
|
title={`Zeilenfehler vom letzten Import (${lastImport.error_details.length})`}
|
||||||
open
|
defaultOpen
|
||||||
>
|
/>
|
||||||
<summary style={{ fontWeight: 600, color: 'var(--text1)' }}>
|
|
||||||
Zeilenfehler vom letzten Import ({lastImport.error_details.length}) — zum Kopieren aufklappen
|
|
||||||
</summary>
|
|
||||||
<pre
|
|
||||||
style={{
|
|
||||||
marginTop: 12,
|
|
||||||
fontSize: 12,
|
|
||||||
overflow: 'auto',
|
|
||||||
maxHeight: 320,
|
|
||||||
background: 'var(--surface2)',
|
|
||||||
padding: 12,
|
|
||||||
borderRadius: 8,
|
|
||||||
color: 'var(--text1)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{JSON.stringify(lastImport.error_details, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</details>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="card" style={{ marginBottom: 16, padding: 16 }}>
|
<div className="card" style={{ marginBottom: 16, padding: 16 }}>
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,13 @@ export function formatFastApiDetail(detail, fallback = '') {
|
||||||
return parts.length ? parts.join(' · ') : fallback || 'Validierungsfehler'
|
return parts.length ? parts.join(' · ') : fallback || 'Validierungsfehler'
|
||||||
}
|
}
|
||||||
if (typeof detail === 'object') {
|
if (typeof detail === 'object') {
|
||||||
|
if (detail.validation?.errors?.length) {
|
||||||
|
const parts = detail.validation.errors
|
||||||
|
.map((e) => (e && typeof e === 'object' ? e.message || e.msg || '' : String(e)))
|
||||||
|
.filter(Boolean)
|
||||||
|
const prefix = detail.message || 'Vorlage ungültig'
|
||||||
|
return parts.length ? `${prefix}: ${parts.join(' · ')}` : prefix
|
||||||
|
}
|
||||||
if (Array.isArray(detail.errors) && detail.errors.length > 0) {
|
if (Array.isArray(detail.errors) && detail.errors.length > 0) {
|
||||||
const parts = detail.errors
|
const parts = detail.errors
|
||||||
.map((e) => {
|
.map((e) => {
|
||||||
|
|
@ -66,6 +73,29 @@ async function req(path, opts={}) {
|
||||||
}
|
}
|
||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
async function readJsonResponse(res) {
|
||||||
|
const text = await res.text()
|
||||||
|
const trimmed = (text || '').trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new Error(res.ok ? 'Leere Antwort' : `HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
if (trimmed.startsWith('<')) {
|
||||||
|
throw new Error(
|
||||||
|
`Server lieferte HTML statt JSON (HTTP ${res.status}). Meist Timeout oder Datei zu groß — erneut versuchen.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let parsed
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(trimmed)
|
||||||
|
} catch {
|
||||||
|
throw new Error(trimmed.slice(0, 180) || `HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(formatFastApiDetail(parsed.detail, JSON.stringify(parsed)))
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
const json=(d)=>({method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(d)})
|
const json=(d)=>({method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(d)})
|
||||||
const jput=(d)=>({method:'PUT', headers:{'Content-Type':'application/json'},body:JSON.stringify(d)})
|
const jput=(d)=>({method:'PUT', headers:{'Content-Type':'application/json'},body:JSON.stringify(d)})
|
||||||
|
|
||||||
|
|
@ -203,11 +233,68 @@ export const api = {
|
||||||
},
|
},
|
||||||
|
|
||||||
// Nutrition
|
// Nutrition
|
||||||
importCsv: async(file)=>{
|
importCsv: async(file, overwriteExisting=false)=>{
|
||||||
const fd=new FormData();fd.append('file',file)
|
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
|
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'),
|
||||||
|
listNutritionRecipes: () => req('/nutrition/recipes'),
|
||||||
|
importFddbLists: async (file) => {
|
||||||
|
const fd = new FormData(); fd.append('file', file)
|
||||||
|
const r = await fetch(`${BASE}/nutrition/recipes/import-fddb-lists`, { method: 'POST', body: fd, headers: hdrs() })
|
||||||
|
return readJsonResponse(r)
|
||||||
|
},
|
||||||
|
applyNutritionRecipe: (recipeId, sourceName) => req(`/nutrition/recipes/${recipeId}/apply`, json({ source_name: sourceName })),
|
||||||
|
exportFoodKnowledge: async () => {
|
||||||
|
const r = await fetch(`${BASE}/nutrition/food-knowledge`, { headers: hdrs() })
|
||||||
|
if (!r.ok) {
|
||||||
|
const text = await r.text()
|
||||||
|
throw new Error(text.trim() || `HTTP ${r.status}`)
|
||||||
|
}
|
||||||
|
const blob = await r.blob()
|
||||||
|
const url = window.URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `mitai-food-knowledge-${new Date().toISOString().split('T')[0]}.json`
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
},
|
||||||
|
importFoodKnowledge: async (file) => {
|
||||||
|
const fd = new FormData(); fd.append('file', file)
|
||||||
|
const r = await fetch(`${BASE}/nutrition/food-knowledge`, { method: 'POST', body: fd, headers: hdrs() })
|
||||||
|
return readJsonResponse(r)
|
||||||
|
},
|
||||||
|
listNutritionMarks: () => req('/nutrition/marks'),
|
||||||
|
putNutritionDayMark: (date, d) => req(`/nutrition/days/${date}/mark`, jput(d)),
|
||||||
|
deleteNutritionDayMark: (date) => req(`/nutrition/days/${date}/mark`, {method:'DELETE'}),
|
||||||
|
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'),
|
||||||
|
adminBlsImportStart: async (kind, file) => {
|
||||||
|
const fd=new FormData();fd.append('file',file)
|
||||||
|
const r=await fetch(`${BASE}/admin/bls/import/jobs?kind=${encodeURIComponent(kind)}`,{method:'POST',body:fd,headers:hdrs()})
|
||||||
|
return readJsonResponse(r)
|
||||||
|
},
|
||||||
|
adminBlsImportJob: (id) => req(`/admin/bls/import/jobs/${id}`),
|
||||||
|
adminBlsImportApply: (id) => req(`/admin/bls/import/jobs/${id}/apply`, {method:'POST'}),
|
||||||
|
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}`),
|
listNutrition: (l=365) => req(`/nutrition?limit=${l}`),
|
||||||
nutritionCorrelations: () => req('/nutrition/correlations'),
|
nutritionCorrelations: () => req('/nutrition/correlations'),
|
||||||
nutritionWeekly: (w=16) => req(`/nutrition/weekly?weeks=${w}`),
|
nutritionWeekly: (w=16) => req(`/nutrition/weekly?weeks=${w}`),
|
||||||
|
|
@ -713,6 +800,7 @@ export const api = {
|
||||||
req(module ? `/csv/mappings?module=${encodeURIComponent(module)}` : '/csv/mappings'),
|
req(module ? `/csv/mappings?module=${encodeURIComponent(module)}` : '/csv/mappings'),
|
||||||
copyCsvMapping: (mappingId, body = null) =>
|
copyCsvMapping: (mappingId, body = null) =>
|
||||||
req(`/csv/mappings/${mappingId}/copy`, body ? json(body) : { method: 'POST' }),
|
req(`/csv/mappings/${mappingId}/copy`, body ? json(body) : { method: 'POST' }),
|
||||||
|
validateCsvMapping: (mappingId) => req(`/csv/mappings/${mappingId}/validate`),
|
||||||
/** Universal-CSV (Issue #21): Zielmodul steckt in der Vorlage; nur file + mapping_id */
|
/** Universal-CSV (Issue #21): Zielmodul steckt in der Vorlage; nur file + mapping_id */
|
||||||
/** Import-Diagnose: keine Datenbank-Schreibung, erste Zeilen + Mapping-Auflösung */
|
/** Import-Diagnose: keine Datenbank-Schreibung, erste Zeilen + Mapping-Auflösung */
|
||||||
diagnoseUniversalCsv: async (file, mappingId, module = null) => {
|
diagnoseUniversalCsv: async (file, mappingId, module = null) => {
|
||||||
|
|
|
||||||
|
|
@ -51,8 +51,8 @@ server {
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_read_timeout 120s; # KI-Calls können länger dauern
|
proxy_read_timeout 600s; # KI-Calls und BLS-Import
|
||||||
client_max_body_size 20M; # CSV + Foto Uploads
|
client_max_body_size 50M; # CSV, Foto, BLS-XLSX
|
||||||
}
|
}
|
||||||
|
|
||||||
# Frontend - React PWA
|
# Frontend - React PWA
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
module.exports = {
|
module.exports = {
|
||||||
testDir: './tests',
|
testDir: './tests',
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
|
workers: 1,
|
||||||
|
globalSetup: require.resolve('./tests/global-setup.js'),
|
||||||
use: {
|
use: {
|
||||||
channel: 'chrome',
|
channel: 'chrome',
|
||||||
headless: true,
|
headless: true,
|
||||||
viewport: { width: 390, height: 844 },
|
viewport: { width: 390, height: 844 },
|
||||||
screenshot: 'only-on-failure',
|
screenshot: 'only-on-failure',
|
||||||
baseURL: 'https://dev.mitai.jinkendo.de',
|
baseURL: 'https://dev.mitai.jinkendo.de',
|
||||||
|
storageState: './tests/.auth/user.json',
|
||||||
},
|
},
|
||||||
reporter: 'list',
|
reporter: 'list',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,61 +1,53 @@
|
||||||
const { test, expect } = require('@playwright/test');
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const { loginViaForm, clickBottomNav } = require('./helpers');
|
||||||
|
|
||||||
const TEST_EMAIL = process.env.TEST_EMAIL || 'lars@stommer.com';
|
test.describe('Login-Flow', () => {
|
||||||
const TEST_PASSWORD = process.env.TEST_PASSWORD || '5112';
|
test.use({ storageState: { cookies: [], origins: [] } });
|
||||||
|
|
||||||
async function login(page) {
|
test('1. Login funktioniert', async ({ page }) => {
|
||||||
await page.goto('/');
|
await loginViaForm(page);
|
||||||
await page.waitForLoadState('networkidle');
|
|
||||||
await page.fill('input[type="email"]', TEST_EMAIL);
|
|
||||||
await page.fill('input[type="password"]', TEST_PASSWORD);
|
|
||||||
await page.click('button:has-text("Anmelden")');
|
|
||||||
await page.waitForLoadState('networkidle');
|
|
||||||
}
|
|
||||||
|
|
||||||
test('1. Login funktioniert', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
await page.fill('input[type="email"]', TEST_EMAIL);
|
|
||||||
await page.fill('input[type="password"]', TEST_PASSWORD);
|
|
||||||
await page.click('button:has-text("Anmelden")');
|
|
||||||
await page.waitForLoadState('networkidle');
|
|
||||||
const loginButton = page.locator('button:has-text("Anmelden")');
|
|
||||||
await expect(loginButton).toHaveCount(0, { timeout: 10000 });
|
|
||||||
await page.screenshot({ path: 'screenshots/01-nach-login.png' });
|
await page.screenshot({ path: 'screenshots/01-nach-login.png' });
|
||||||
console.log('Login erfolgreich');
|
console.log('Login erfolgreich');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('2. Dashboard laedt ohne Fehler', async ({ page }) => {
|
test('2. Dashboard laedt ohne Fehler', async ({ page }) => {
|
||||||
await login(page);
|
await page.goto('/');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
|
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
|
||||||
await page.screenshot({ path: 'screenshots/02-dashboard.png' });
|
await page.screenshot({ path: 'screenshots/02-dashboard.png' });
|
||||||
console.log('Dashboard OK');
|
console.log('Dashboard OK');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('3. Erfassung erreichbar', async ({ page }) => {
|
test('3. Erfassen erreichbar', async ({ page }) => {
|
||||||
await login(page);
|
await page.goto('/');
|
||||||
await page.click('text=Erfassung');
|
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('networkidle');
|
||||||
|
await clickBottomNav(page, 'Erfassen');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await expect(page).toHaveURL(/\/capture/);
|
||||||
await page.screenshot({ path: 'screenshots/03-erfassung.png' });
|
await page.screenshot({ path: 'screenshots/03-erfassung.png' });
|
||||||
console.log('Erfassung OK');
|
console.log('Erfassen OK');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('4. Analyse erreichbar', async ({ page }) => {
|
test('4. Analyse erreichbar', async ({ page }) => {
|
||||||
await login(page);
|
await page.goto('/');
|
||||||
await page.click('text=Analyse');
|
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('networkidle');
|
||||||
|
await clickBottomNav(page, 'Analyse');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await expect(page).toHaveURL(/\/analysis/);
|
||||||
await page.screenshot({ path: 'screenshots/04-analyse.png' });
|
await page.screenshot({ path: 'screenshots/04-analyse.png' });
|
||||||
console.log('Analyse OK');
|
console.log('Analyse OK');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('5. Keine kritischen Console-Fehler', async ({ page }) => {
|
test('5. Keine kritischen Console-Fehler', async ({ page }) => {
|
||||||
const errors = [];
|
const errors = [];
|
||||||
page.on('console', msg => {
|
page.on('console', (msg) => {
|
||||||
if (msg.type() === 'error') errors.push(msg.text());
|
if (msg.type() === 'error') errors.push(msg.text());
|
||||||
});
|
});
|
||||||
await login(page);
|
await page.goto('/');
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('networkidle');
|
||||||
const kritisch = errors.filter(e =>
|
const kritisch = errors.filter(
|
||||||
!e.includes('favicon') && !e.includes('sourceMap') && !e.includes('404')
|
(e) => !e.includes('favicon') && !e.includes('sourceMap') && !e.includes('404')
|
||||||
);
|
);
|
||||||
if (kritisch.length > 0) {
|
if (kritisch.length > 0) {
|
||||||
console.log('Console-Fehler:', kritisch.join(', '));
|
console.log('Console-Fehler:', kritisch.join(', '));
|
||||||
|
|
@ -63,3 +55,37 @@ test('5. Keine kritischen Console-Fehler', async ({ page }) => {
|
||||||
console.log('Keine kritischen Console-Fehler');
|
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();
|
||||||
|
await expect(page.getByRole('button', { name: /FDDB-Listen/ })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /Zuordnungen & Listen exportieren/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FEATURE: Settings — Ernährungs-Import-Policy', async ({ page }) => {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
|
||||||
64
tests/global-setup.js
Normal file
64
tests/global-setup.js
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
const { chromium } = require('@playwright/test');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const TEST_EMAIL = process.env.TEST_EMAIL || 'lars@stommer.com';
|
||||||
|
const TEST_PASSWORD = process.env.TEST_PASSWORD || '5112';
|
||||||
|
const AUTH_FILE = path.join(__dirname, '.auth', 'user.json');
|
||||||
|
|
||||||
|
async function tryLogin(page, baseURL) {
|
||||||
|
await page.goto(`${baseURL}/`);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
const loginBtn = page.locator('button:has-text("Anmelden")');
|
||||||
|
if ((await loginBtn.count()) === 0) {
|
||||||
|
return { ok: true, status: 200 };
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.fill('input[type="email"]', TEST_EMAIL);
|
||||||
|
await page.fill('input[type="password"]', TEST_PASSWORD);
|
||||||
|
|
||||||
|
const responsePromise = page
|
||||||
|
.waitForResponse(
|
||||||
|
(r) => r.url().includes('/api/auth/login') && r.request().method() === 'POST',
|
||||||
|
{ timeout: 15000 }
|
||||||
|
)
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
|
await page.click('button:has-text("Anmelden")');
|
||||||
|
const response = await responsePromise;
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
const loginVisible = await page.locator('button:has-text("Anmelden")').count();
|
||||||
|
return { ok: loginVisible === 0, status: response?.status() ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = async (config) => {
|
||||||
|
fs.mkdirSync(path.dirname(AUTH_FILE), { recursive: true });
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const baseURL = config.projects?.[0]?.use?.baseURL || 'https://dev.mitai.jinkendo.de';
|
||||||
|
|
||||||
|
const waits = [0, 15000, 30000, 45000];
|
||||||
|
let lastStatus = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < waits.length; i++) {
|
||||||
|
if (waits[i] > 0) {
|
||||||
|
console.log(`Global setup: warte ${waits[i] / 1000}s (Login-Rate-Limit 5/min)…`);
|
||||||
|
await page.waitForTimeout(waits[i]);
|
||||||
|
}
|
||||||
|
const result = await tryLogin(page, baseURL);
|
||||||
|
lastStatus = result.status;
|
||||||
|
if (result.ok) {
|
||||||
|
await page.context().storageState({ path: AUTH_FILE });
|
||||||
|
await browser.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
throw new Error(
|
||||||
|
`Global setup: Login fehlgeschlagen (letzter HTTP-Status: ${lastStatus ?? 'unbekannt'})`
|
||||||
|
);
|
||||||
|
};
|
||||||
23
tests/helpers.js
Normal file
23
tests/helpers.js
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
const { expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
const TEST_EMAIL = process.env.TEST_EMAIL || 'lars@stommer.com';
|
||||||
|
const TEST_PASSWORD = process.env.TEST_PASSWORD || '5112';
|
||||||
|
|
||||||
|
/** Nutzt storageState aus global-setup; nur für Tests nötig, die explizit Login prüfen. */
|
||||||
|
async function loginViaForm(page) {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.fill('input[type="email"]', TEST_EMAIL);
|
||||||
|
await page.fill('input[type="password"]', TEST_PASSWORD);
|
||||||
|
await page.click('button:has-text("Anmelden")');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await expect(page.locator('button:has-text("Anmelden")')).toHaveCount(0, { timeout: 15000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickBottomNav(page, label) {
|
||||||
|
const link = page.locator('nav.bottom-nav a.nav-item').filter({ hasText: label });
|
||||||
|
await expect(link).toBeVisible({ timeout: 10000 });
|
||||||
|
await link.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { TEST_EMAIL, TEST_PASSWORD, loginViaForm, clickBottomNav };
|
||||||
52
tests/issue-audit.spec.js
Normal file
52
tests/issue-audit.spec.js
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
/**
|
||||||
|
* Gitea Issue-Audit — UI-Verifikation gegen dev.mitai.jinkendo.de
|
||||||
|
* Einmaliger Audit-Lauf; keine dauerhafte CI-Pflicht.
|
||||||
|
*/
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
test.describe('Issue-Audit UI', () => {
|
||||||
|
test('#40 Logout-Button im Mobile-Header sichtbar', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
const logoutBtn = page.locator('header.app-header--mobile button[title="Abmelden"]');
|
||||||
|
await expect(logoutBtn).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('#25 Goals-Seite erreichbar und rendert Inhalt', async ({ page }) => {
|
||||||
|
await page.goto('/goals');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await expect(page.locator('body')).not.toContainText('404', { timeout: 5000 });
|
||||||
|
const hasGoalsUi =
|
||||||
|
(await page.getByText(/Ziel/i).count()) > 0 ||
|
||||||
|
(await page.locator('.card').count()) > 0;
|
||||||
|
expect(hasGoalsUi).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('#65 Dashboard-Layout-Konfiguration erreichbar', async ({ page }) => {
|
||||||
|
await page.goto('/settings/dashboard-layout');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
const body = await page.locator('body').innerText();
|
||||||
|
expect(body).toMatch(/Widget|Übersicht|Layout|Dashboard/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('#30 Desktop-Sidebar bei breitem Viewport', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.setViewportSize({ width: 1280, height: 800 });
|
||||||
|
await expect(page.locator('aside.desktop-sidebar')).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('#38 Nutrition CSV Import — FDDB-Panel mit Usage-Integration', async ({ page }) => {
|
||||||
|
await page.goto('/nutrition');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.getByRole('button', { name: /Import/i }).click();
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user