feat: BLS-Stammdaten, FDDB-Mapping und Item-Tagebuch (#106)
Katalog, lernendes Mapping ohne KI, optionale Items und Import-Policy. Playwright-Smoke und Issue-Audit um Ernährung/Zuordnen/API ergänzt. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
72d101415f
commit
919c77fcf8
|
|
@ -1,6 +1,6 @@
|
|||
# Gitea Issues – Landkarte (Auswertung)
|
||||
|
||||
**Quelle:** Gitea `Lars/mitai-jinkendo`, Stand **2026-04-11** (Abfrage `state=all`, ergänzt: #71, #76).
|
||||
**Quelle:** Gitea `Lars/mitai-jinkendo`, Stand **2026-04-11** (Abfrage `state=all`, ergänzt: #71, #75, #76, #106, 2026-09-12).
|
||||
**URL:** http://192.168.2.144:3000/Lars/mitai-jinkendo/issues
|
||||
|
||||
Dieses Dokument ist ein **Orientierungs-Index** für Agenten und Entwickler. Verbindliches Tracking bleibt **in Gitea**; hier: Kategorien, Dubletten-Hinweise, grobe Prioritätseinschätzung.
|
||||
|
|
@ -82,6 +82,14 @@ Dieses Dokument ist ein **Orientierungs-Index** für Agenten und Entwickler. Ver
|
|||
|---|--------|
|
||||
| 37 | Feature-Enforcement für Activity CSV-Import |
|
||||
| 38 | Feature-Enforcement für Nutrition CSV-Import UI |
|
||||
| 71 | Universal CSV Import: Dry-Run, Mapping-Validierung, Fehler-Hints |
|
||||
|
||||
### Ernährung / BLS
|
||||
|
||||
| # | Titel |
|
||||
|---|--------|
|
||||
| 75 | Ernährung: Zucker/Ballaststoffe, Lebensmittelqualität, Timing (Folge nach #106) |
|
||||
| 106 | BLS-Stammdaten, FDDB-Mapping und Item-Tagebuch |
|
||||
|
||||
### Qualität / Sonstiges
|
||||
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ _Dieser Ordner `.claude/docs/` ist per `.gitignore`-Ausnahme **versioniert** (Sp
|
|||
| Dashboard-Widgets | `technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md` | Widget-Katalog + Registrierung (siehe Guide) |
|
||||
| Training Profiler / Resolver | `technical/TRAINING_PROFILE_RESOLVER_LAYER1.md`, `functional/TRAINING_TYPE_PROFILES.md` | Resolver-Module wie im Guide genannt |
|
||||
| Universal CSV Import | `technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` | `backend/csv_parser/`, `routers/csv_import.py`, `routers/admin_csv_templates.py` |
|
||||
| BLS / Lebensmittel | `functional/BLS_FOOD_REFERENCE.md`, `technical/BLS_FOOD_REFERENCE.md` | Migration 062, `backend/bls/`, `data_layer/food_mapping.py` |
|
||||
| **Designprinzipien (Produktfamilie)** | **`jinkendo-foundation/design-principles/README.md`** | Querschnittsmuster; Foundation für Schwester-Apps |
|
||||
| Aktivität Produktionsreife | `technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` (+ EAV-Guide) | `backend/data_layer/activity_session_metrics.py`, `activity_metrics.py`, CSV-Orchestrierung |
|
||||
| Mitgliedschaft / Features | `technical/MEMBERSHIP_SYSTEM.md`, `architecture/FEATURE_ENFORCEMENT.md` | `backend/auth.py`, Feature-Logging, Router mit Enforcement |
|
||||
|
|
|
|||
21
.claude/docs/functional/BLS_FOOD_REFERENCE.md
Normal file
21
.claude/docs/functional/BLS_FOOD_REFERENCE.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# BLS-Lebensmittelreferenz und FDDB-Mapping
|
||||
|
||||
**Stand:** 2026-09-12 · **Status:** Phase 1 (Umsetzung)
|
||||
|
||||
## WAS
|
||||
|
||||
Optionale Grundlage für verlässliche Nährwerte: offizieller Bundeslebensmittelschlüssel (BLS) 4.0 plus manuelle Katalogerweiterung, lernendes Mapping von FDDB-Bezeichnern, persistierte Tagebuchzeilen. Reine Tagesmakros bleiben First Class.
|
||||
|
||||
## Fachliche Regeln
|
||||
|
||||
- BLS-Code (`bls_code`, Stoff-`attr_key`) bleibt die stabile Identität bei Reimports.
|
||||
- BLS 4.0 (~7140 Lebensmittel) ist frei nutzbar (MRI / blsdb.de); Dateien nicht im Git.
|
||||
- Mapping ohne KI: Normalisierung + exakter Lookup (User vor Global) + Bestätigung neuer Namen.
|
||||
- Gelernte Zuordnungen bleiben dauerhaft, sind aber änder- und löschbar.
|
||||
- Ungemappte / Fertiggerichte: FDDB-Makros, keine erfundenen Mikros.
|
||||
- Fasten und „unvollständig“ sind explizite Marken, kein Auto-Schluss aus fehlendem Import.
|
||||
- Import-Policy (Profil): nachfragen / Katalog überschreiben / FDDB überschreiben / Makros behalten.
|
||||
|
||||
## Später
|
||||
|
||||
Platzhalter (Registry) für Mikros, Esszeitpunkte (`logged_at`), Fasten; Bezug Gitea #106 (Grundlage) und #75 (Folge).
|
||||
31
.claude/docs/technical/BLS_FOOD_REFERENCE.md
Normal file
31
.claude/docs/technical/BLS_FOOD_REFERENCE.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# BLS Food Reference – technische Spec
|
||||
|
||||
**Stand:** 2026-09-12 · Migration **062**
|
||||
|
||||
## Tabellen
|
||||
|
||||
- `food_attributes` — dynamischer Stoff-/Merkmalskatalog (`attr_key`, `data_type`, `origin`)
|
||||
- `food_catalog` — Lebensmittel (`bls_code` UNIQUE bei official_bls; manuell ohne BLS-Code)
|
||||
- `food_attribute_values` — typisiertes EAV
|
||||
- `food_name_mappings` — FDDB-Name → `food_id` (User / global)
|
||||
- `nutrition_items` — Tagebuchzeilen inkl. `logged_at`
|
||||
- `nutrition_daily_nutrients` — Tages-Rollup numerischer Attribute
|
||||
- `nutrition_day_marks` — `fasting` | `incomplete`
|
||||
|
||||
## Layer 1
|
||||
|
||||
- `data_layer/food_mapping.py` — Normalisierung, Lookup, Learn, Apply, Delete
|
||||
- `data_layer/nutrition_items.py` — Ingest, drei Makro-Summen, Policy, `resolve_*_attributes`
|
||||
|
||||
## Import
|
||||
|
||||
BLS: Admin-Upload Components + Daten-XLSX (`backend/bls/parser.py`). Upsert über `bls_code` / `attr_key`, nie Delete+Insert offizieller Zeilen.
|
||||
|
||||
FDDB: Items persistieren; `nutrition_log` nur bei leerem Tag oder laut Policy / Bestätigung.
|
||||
|
||||
## Router
|
||||
|
||||
- `/api/bls/*` — Suche, eigene Foods, eigene Mappings
|
||||
- `/api/admin/bls/*` — Import, Katalog, Attribute
|
||||
- `/api/admin/food-mappings` — Admin-CRUD
|
||||
- `/api/nutrition/*` — Items, Unmapped, Bulk-Map, Marken, Konflikt-Resolve
|
||||
|
|
@ -117,6 +117,14 @@ frontend/src/
|
|||
- **`main.py`:** `import placeholder_registrations` beim Start, damit die Registry (**114 Keys**, deckungsgleich `PLACEHOLDER_MAP`) und `get_placeholder_catalog()` ohne vorherigen Export-Request konsistent sind.
|
||||
- **`placeholder_resolver.py`:** `{{top_goal_progress_pct}}` nutzt `_safe_int` statt `_safe_str` (Verdrahtung zu `scores.get_top_priority_goal` korrigiert).
|
||||
|
||||
### Updates (12.09.2026 - BLS-Stammdaten, FDDB-Mapping, Item-Tagebuch)
|
||||
|
||||
- **Migration 062:** `food_catalog` (BLS-Code bleibt Identität), dynamische `food_attributes` + EAV, `food_name_mappings`, `nutrition_items`, `nutrition_daily_nutrients`, `nutrition_day_marks`, Import-Policy am Profil.
|
||||
- **Admin:** Gruppe Ernährung — BLS-Import, Katalog, Attribute, Mappings.
|
||||
- **Nutzer:** Einzelerfassung unverändert; Tab Zuordnen; Fasten/Lücke; Import-Abgleich.
|
||||
- **Gitea #106:** BLS-Stammdaten, FDDB-Mapping, Item-Tagebuch — http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/106
|
||||
- **Doku:** `.claude/docs/functional/BLS_FOOD_REFERENCE.md`, `.claude/docs/technical/BLS_FOOD_REFERENCE.md`, `docs/issues/issue-bls-food-mapping.md`. Folge #75.
|
||||
|
||||
### Updates (11.04.2026 - Gitea #75, nutrition_score Registry)
|
||||
|
||||
- **Gitea #75** (offen): Zucker/Ballaststoffe/Lebensmittelqualität, automatisches Lebensmittelprofil, später Mahlzeiten-Timing/Abgleich mit Training — http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/75
|
||||
|
|
|
|||
1
backend/bls/__init__.py
Normal file
1
backend/bls/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""BLS 4.0 ingest (official MRI XLSX)."""
|
||||
126
backend/bls/import_service.py
Normal file
126
backend/bls/import_service.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
"""Apply parsed BLS 4.0 data: upsert attributes and foods, never delete official rows."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def upsert_attributes(cur, attributes: list[dict[str, Any]]) -> dict[str, int]:
|
||||
inserted = updated = 0
|
||||
for a in attributes:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO food_attributes
|
||||
(attr_key, name_de, name_en, unit, category, data_type, origin, sort_order, updated_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, 'official_bls', %s, NOW())
|
||||
ON CONFLICT (attr_key) DO UPDATE SET
|
||||
name_de = EXCLUDED.name_de,
|
||||
name_en = COALESCE(EXCLUDED.name_en, food_attributes.name_en),
|
||||
unit = COALESCE(EXCLUDED.unit, food_attributes.unit),
|
||||
category = COALESCE(EXCLUDED.category, food_attributes.category),
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
updated_at = NOW()
|
||||
WHERE food_attributes.origin = 'official_bls'
|
||||
RETURNING (xmax = 0) AS inserted
|
||||
""",
|
||||
(
|
||||
a["attr_key"], a["name_de"], a.get("name_en"), a.get("unit"),
|
||||
a.get("category"), a.get("data_type") or "num_per_100g",
|
||||
a.get("sort_order") or 0,
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row and row.get("inserted"):
|
||||
inserted += 1
|
||||
else:
|
||||
updated += 1
|
||||
return {"inserted": inserted, "updated": updated, "total": len(attributes)}
|
||||
|
||||
|
||||
def upsert_foods(cur, foods: list[dict[str, Any]], bls_version: str = "4.0") -> dict[str, int]:
|
||||
cur.execute("SELECT attr_key, id FROM food_attributes")
|
||||
attr_ids = {r["attr_key"]: r["id"] for r in cur.fetchall()}
|
||||
inserted = updated = values_written = 0
|
||||
for f in foods:
|
||||
code = f["bls_code"]
|
||||
cur.execute("SELECT id FROM food_catalog WHERE bls_code = %s", (code,))
|
||||
existing = cur.fetchone()
|
||||
if existing:
|
||||
food_id = existing["id"]
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE food_catalog
|
||||
SET name_de=%s, name_en=%s, food_group=%s, bls_version=%s,
|
||||
catalog_kind='official_bls', source='bls_4.0', is_active=true, updated_at=NOW()
|
||||
WHERE id=%s AND catalog_kind='official_bls'
|
||||
""",
|
||||
(f["name_de"], f.get("name_en"), f.get("food_group"), bls_version, food_id),
|
||||
)
|
||||
updated += 1
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO food_catalog
|
||||
(bls_code, name_de, name_en, food_group, catalog_kind, bls_version, source)
|
||||
VALUES (%s, %s, %s, %s, 'official_bls', %s, 'bls_4.0')
|
||||
RETURNING id
|
||||
""",
|
||||
(code, f["name_de"], f.get("name_en"), f.get("food_group"), bls_version),
|
||||
)
|
||||
food_id = cur.fetchone()["id"]
|
||||
inserted += 1
|
||||
for val in f.get("values") or []:
|
||||
aid = attr_ids.get(val["attr_key"])
|
||||
if not aid:
|
||||
continue
|
||||
if val.get("is_trace"):
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO food_attribute_values
|
||||
(food_id, attribute_id, value_num, is_trace, origin_code, reference_text, updated_at)
|
||||
VALUES (%s, %s, NULL, true, %s, %s, NOW())
|
||||
ON CONFLICT (food_id, attribute_id) DO UPDATE SET
|
||||
value_num = NULL, is_trace = true,
|
||||
origin_code = EXCLUDED.origin_code,
|
||||
reference_text = EXCLUDED.reference_text,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
(food_id, aid, val.get("origin_code"), val.get("reference_text")),
|
||||
)
|
||||
elif val.get("value_num") is None:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO food_attribute_values
|
||||
(food_id, attribute_id, value_num, is_trace, origin_code, reference_text, updated_at)
|
||||
VALUES (%s, %s, NULL, false, %s, %s, NOW())
|
||||
ON CONFLICT (food_id, attribute_id) DO UPDATE SET
|
||||
value_num = NULL, is_trace = false,
|
||||
origin_code = EXCLUDED.origin_code,
|
||||
reference_text = EXCLUDED.reference_text,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
(food_id, aid, val.get("origin_code"), val.get("reference_text")),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO food_attribute_values
|
||||
(food_id, attribute_id, value_num, is_trace, origin_code, reference_text, updated_at)
|
||||
VALUES (%s, %s, %s, false, %s, %s, NOW())
|
||||
ON CONFLICT (food_id, attribute_id) DO UPDATE SET
|
||||
value_num = EXCLUDED.value_num, is_trace = false,
|
||||
origin_code = EXCLUDED.origin_code,
|
||||
reference_text = EXCLUDED.reference_text,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
(
|
||||
food_id, aid, val["value_num"],
|
||||
val.get("origin_code"), val.get("reference_text"),
|
||||
),
|
||||
)
|
||||
values_written += 1
|
||||
return {
|
||||
"foods_inserted": inserted,
|
||||
"foods_updated": updated,
|
||||
"values_written": values_written,
|
||||
"foods_total": len(foods),
|
||||
}
|
||||
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}
|
||||
|
|
@ -223,6 +223,40 @@ def _import_nutrition(
|
|||
|
||||
skipped_groups = sum(n.get("rows_in_group", 0) for n in (agg_notes or []) if n.get("error") == "mehrere_zeilen_pro_schluessel")
|
||||
|
||||
item_rows = []
|
||||
for mapped in mapped_rows:
|
||||
name = mapped.get("food_name")
|
||||
if not name:
|
||||
continue
|
||||
d_item = coerce_date(mapped.get("date"))
|
||||
if d_item is None:
|
||||
continue
|
||||
item_rows.append({
|
||||
"date": d_item.isoformat(),
|
||||
"logged_at": mapped.get("logged_at") or mapped.get("date"),
|
||||
"food_name": str(name).strip(),
|
||||
"quantity_raw": mapped.get("quantity_raw"),
|
||||
"kcal": mapped.get("kcal"),
|
||||
"protein_g": mapped.get("protein_g"),
|
||||
"fat_g": mapped.get("fat_g"),
|
||||
"carbs_g": mapped.get("carbs_g"),
|
||||
})
|
||||
if item_rows:
|
||||
from data_layer.nutrition_items import get_import_policy, replace_csv_items_for_dates
|
||||
|
||||
policy = get_import_policy(cur, profile_id)
|
||||
ingest = replace_csv_items_for_dates(cur, profile_id, item_rows, policy=policy)
|
||||
return {
|
||||
"rows_total": rows_total,
|
||||
"inserted": ingest.get("new_log_days", 0),
|
||||
"updated": max(0, ingest.get("days_written", 0) - ingest.get("new_log_days", 0)),
|
||||
"skipped": skipped_groups,
|
||||
"new_entries": ingest.get("new_log_days", 0),
|
||||
"items_written": ingest.get("items_written", 0),
|
||||
"conflicts": ingest.get("conflicts") or [],
|
||||
"policy": ingest.get("policy"),
|
||||
}
|
||||
|
||||
inserted = 0
|
||||
updated = 0
|
||||
new_entries = 0
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ MODULE_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
|||
"protein_g": {"type": "float", "required": False, "min": 0, "unit": "g"},
|
||||
"fat_g": {"type": "float", "required": False, "min": 0, "unit": "g"},
|
||||
"carbs_g": {"type": "float", "required": False, "min": 0, "unit": "g"},
|
||||
"food_name": {"type": "string", "required": False, "label_de": "Lebensmittel"},
|
||||
"quantity_raw": {"type": "string", "required": False, "label_de": "Menge"},
|
||||
},
|
||||
"duplicate_key": ["profile_id", "date"],
|
||||
"duplicate_strategy": "update",
|
||||
|
|
|
|||
194
backend/data_layer/food_mapping.py
Normal file
194
backend/data_layer/food_mapping.py
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
"""FDDB → food_catalog mapping: normalize, lookup (user then global), learn, apply."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
from typing import Any
|
||||
|
||||
LEADING_QTY_RE = re.compile(
|
||||
r"^\s*\d+(?:[.,]\d+)?\s*(?:g|kg|ml|l|stück|stk|st\.?|portion(?:en)?)\b[\s,.:\-–]*",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
MULTISPACE_RE = re.compile(r"\s+")
|
||||
DECIMAL_IN_NAME_RE = re.compile(r"(\d),(\d)")
|
||||
|
||||
|
||||
def normalize_food_name(raw: str | None) -> str:
|
||||
if not raw:
|
||||
return ""
|
||||
s = unicodedata.normalize("NFKC", str(raw)).strip().strip('"').strip("'")
|
||||
s = LEADING_QTY_RE.sub("", s)
|
||||
s = DECIMAL_IN_NAME_RE.sub(r"\1.\2", s)
|
||||
s = MULTISPACE_RE.sub(" ", s).strip().lower()
|
||||
return s
|
||||
|
||||
|
||||
def parse_quantity_g(raw: str | None) -> float | None:
|
||||
if raw is None or str(raw).strip() == "":
|
||||
return None
|
||||
text = str(raw).strip().replace(",", ".")
|
||||
m = re.match(r"^\s*(\d+(?:\.\d+)?)\s*(g|gramm)?\s*$", text, re.IGNORECASE)
|
||||
if m:
|
||||
return round(float(m.group(1)), 3)
|
||||
return None
|
||||
|
||||
|
||||
def get_food_mapping_with_cursor(
|
||||
cur,
|
||||
source_name: str,
|
||||
profile_id: str | None = None,
|
||||
source_system: str = "fddb",
|
||||
) -> dict[str, Any] | None:
|
||||
norm = normalize_food_name(source_name)
|
||||
if not norm:
|
||||
return None
|
||||
if profile_id:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT m.id AS mapping_id, m.food_id, m.profile_id, m.source,
|
||||
f.bls_code, f.name_de, f.catalog_kind
|
||||
FROM food_name_mappings m
|
||||
JOIN food_catalog f ON f.id = m.food_id
|
||||
WHERE m.source_system = %s AND m.source_name_normalized = %s
|
||||
AND m.profile_id = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(source_system, norm, profile_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
return dict(row)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT m.id AS mapping_id, m.food_id, m.profile_id, m.source,
|
||||
f.bls_code, f.name_de, f.catalog_kind
|
||||
FROM food_name_mappings m
|
||||
JOIN food_catalog f ON f.id = m.food_id
|
||||
WHERE m.source_system = %s AND m.source_name_normalized = %s
|
||||
AND m.profile_id IS NULL
|
||||
LIMIT 1
|
||||
""",
|
||||
(source_system, norm),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def upsert_food_mapping(
|
||||
cur,
|
||||
*,
|
||||
source_name_raw: str,
|
||||
food_id: str,
|
||||
profile_id: str | None,
|
||||
source: str = "bulk",
|
||||
source_system: str = "fddb",
|
||||
) -> int:
|
||||
norm = normalize_food_name(source_name_raw)
|
||||
if not norm:
|
||||
raise ValueError("Leerer Lebensmittelname")
|
||||
if profile_id:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id FROM food_name_mappings
|
||||
WHERE source_system = %s AND source_name_normalized = %s AND profile_id = %s
|
||||
""",
|
||||
(source_system, norm, profile_id),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id FROM food_name_mappings
|
||||
WHERE source_system = %s AND source_name_normalized = %s AND profile_id IS NULL
|
||||
""",
|
||||
(source_system, norm),
|
||||
)
|
||||
existing = cur.fetchone()
|
||||
raw = source_name_raw.strip()
|
||||
if existing:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE food_name_mappings
|
||||
SET food_id = %s, source_name_raw = %s, source = %s, updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(food_id, raw, source, existing["id"]),
|
||||
)
|
||||
return int(existing["id"])
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO food_name_mappings
|
||||
(source_system, source_name_raw, source_name_normalized, food_id, profile_id, source, updated_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, NOW())
|
||||
RETURNING id
|
||||
""",
|
||||
(source_system, raw, norm, food_id, profile_id, source),
|
||||
)
|
||||
return int(cur.fetchone()["id"])
|
||||
|
||||
|
||||
def apply_mapping_to_items(cur, profile_id: str, source_name_normalized: str, food_id: str, mapping_id: int) -> int:
|
||||
origin = _value_origin_for_food(cur, food_id)
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE nutrition_items
|
||||
SET food_id = %s, mapping_id = %s, value_origin = %s, updated_at = NOW()
|
||||
WHERE profile_id = %s AND source_name_normalized = %s
|
||||
""",
|
||||
(food_id, mapping_id, origin, profile_id, source_name_normalized),
|
||||
)
|
||||
return cur.rowcount or 0
|
||||
|
||||
|
||||
def clear_mapping_from_items(cur, profile_id: str, source_name_normalized: str) -> int:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE nutrition_items
|
||||
SET food_id = NULL, mapping_id = NULL, value_origin = 'fddb', updated_at = NOW()
|
||||
WHERE profile_id = %s AND source_name_normalized = %s
|
||||
""",
|
||||
(profile_id, source_name_normalized),
|
||||
)
|
||||
return cur.rowcount or 0
|
||||
|
||||
|
||||
def _value_origin_for_food(cur, food_id: str) -> str:
|
||||
cur.execute("SELECT catalog_kind FROM food_catalog WHERE id = %s", (food_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return "fddb"
|
||||
kind = row["catalog_kind"]
|
||||
if kind == "official_bls":
|
||||
return "bls"
|
||||
return "manual_catalog"
|
||||
|
||||
|
||||
def suggest_catalog_foods(cur, query: str, profile_id: str | None, limit: int = 8) -> list[dict]:
|
||||
q = (query or "").strip()
|
||||
if not q:
|
||||
return []
|
||||
like = f"%{q}%"
|
||||
norm = normalize_food_name(q)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, bls_code, name_de, name_en, catalog_kind, food_group
|
||||
FROM food_catalog
|
||||
WHERE is_active = true
|
||||
AND (
|
||||
owner_profile_id IS NULL
|
||||
OR owner_profile_id = %s
|
||||
)
|
||||
AND (
|
||||
name_de ILIKE %s OR COALESCE(name_en, '') ILIKE %s
|
||||
OR COALESCE(bls_code, '') ILIKE %s
|
||||
OR lower(name_de) = %s
|
||||
)
|
||||
ORDER BY
|
||||
CASE WHEN lower(name_de) = %s THEN 0
|
||||
WHEN COALESCE(bls_code, '') ILIKE %s THEN 1
|
||||
ELSE 2 END,
|
||||
name_de
|
||||
LIMIT %s
|
||||
""",
|
||||
(profile_id, like, like, like, norm, norm, q, limit),
|
||||
)
|
||||
return [dict(r) for r in cur.fetchall()]
|
||||
437
backend/data_layer/nutrition_items.py
Normal file
437
backend/data_layer/nutrition_items.py
Normal file
|
|
@ -0,0 +1,437 @@
|
|||
"""Nutrition diary items, three macro sums, import policy, attribute resolve."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
from data_layer.food_mapping import (
|
||||
get_food_mapping_with_cursor,
|
||||
normalize_food_name,
|
||||
parse_quantity_g,
|
||||
)
|
||||
|
||||
MACRO_ATTR_KEYS = {
|
||||
"kcal": "ENERCC",
|
||||
"protein_g": "PROT625",
|
||||
"fat_g": "FAT",
|
||||
"carbs_g": "CHO",
|
||||
}
|
||||
|
||||
POLICIES = frozenset({"prompt", "overwrite_catalog", "overwrite_fddb", "keep_existing"})
|
||||
|
||||
|
||||
def _f(v: Any) -> float:
|
||||
if v is None or v == "":
|
||||
return 0.0
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _round_macros(d: dict[str, float]) -> dict[str, float]:
|
||||
return {
|
||||
"kcal": round(_f(d.get("kcal")), 1),
|
||||
"protein_g": round(_f(d.get("protein_g")), 1),
|
||||
"fat_g": round(_f(d.get("fat_g")), 1),
|
||||
"carbs_g": round(_f(d.get("carbs_g")), 1),
|
||||
}
|
||||
|
||||
|
||||
def macros_differ(a: dict[str, float], b: dict[str, float]) -> bool:
|
||||
aa, bb = _round_macros(a), _round_macros(b)
|
||||
return any(aa[k] != bb[k] for k in aa)
|
||||
|
||||
|
||||
def get_import_policy(cur, profile_id: str) -> str:
|
||||
cur.execute(
|
||||
"SELECT nutrition_import_conflict_policy FROM profiles WHERE id = %s",
|
||||
(profile_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return "prompt"
|
||||
pol = row.get("nutrition_import_conflict_policy") or "prompt"
|
||||
return pol if pol in POLICIES else "prompt"
|
||||
|
||||
|
||||
def catalog_macros_for_item(cur, food_id: str | None, quantity_g: float | None) -> dict[str, float] | None:
|
||||
if not food_id or quantity_g is None or quantity_g <= 0:
|
||||
return None
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT a.attr_key, v.value_num, v.is_trace
|
||||
FROM food_attribute_values v
|
||||
JOIN food_attributes a ON a.id = v.attribute_id
|
||||
WHERE v.food_id = %s AND a.attr_key = ANY(%s) AND a.data_type = 'num_per_100g'
|
||||
""",
|
||||
(food_id, list(MACRO_ATTR_KEYS.values())),
|
||||
)
|
||||
by_key = {r["attr_key"]: r for r in cur.fetchall()}
|
||||
if not by_key:
|
||||
return None
|
||||
out = {}
|
||||
factor = float(quantity_g) / 100.0
|
||||
missing = False
|
||||
for field, key in MACRO_ATTR_KEYS.items():
|
||||
row = by_key.get(key)
|
||||
if not row or row.get("is_trace") or row.get("value_num") is None:
|
||||
missing = True
|
||||
break
|
||||
out[field] = float(row["value_num"]) * factor
|
||||
return None if missing else out
|
||||
|
||||
|
||||
def compute_day_macro_sums(cur, profile_id: str, day: date | str) -> dict[str, Any]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT kcal, protein_g, fat_g, carbs_g, macro_origin, has_items
|
||||
FROM nutrition_log WHERE profile_id = %s AND date = %s
|
||||
""",
|
||||
(profile_id, day),
|
||||
)
|
||||
existing = cur.fetchone()
|
||||
existing_macros = (
|
||||
_round_macros(existing)
|
||||
if existing
|
||||
else None
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT food_id, quantity_g, fddb_kcal, fddb_protein_g, fddb_fat_g, fddb_carbs_g, value_origin
|
||||
FROM nutrition_items
|
||||
WHERE profile_id = %s AND date = %s
|
||||
""",
|
||||
(profile_id, day),
|
||||
)
|
||||
items = cur.fetchall()
|
||||
fddb = {"kcal": 0.0, "protein_g": 0.0, "fat_g": 0.0, "carbs_g": 0.0}
|
||||
catalog = {"kcal": 0.0, "protein_g": 0.0, "fat_g": 0.0, "carbs_g": 0.0}
|
||||
mapped = unmapped = 0
|
||||
used_bls = used_fddb = False
|
||||
for it in items:
|
||||
fddb["kcal"] += _f(it.get("fddb_kcal"))
|
||||
fddb["protein_g"] += _f(it.get("fddb_protein_g"))
|
||||
fddb["fat_g"] += _f(it.get("fddb_fat_g"))
|
||||
fddb["carbs_g"] += _f(it.get("fddb_carbs_g"))
|
||||
cat = catalog_macros_for_item(cur, it.get("food_id"), it.get("quantity_g"))
|
||||
if cat:
|
||||
mapped += 1
|
||||
used_bls = True
|
||||
for k in catalog:
|
||||
catalog[k] += cat[k]
|
||||
else:
|
||||
unmapped += 1
|
||||
used_fddb = True
|
||||
catalog["kcal"] += _f(it.get("fddb_kcal"))
|
||||
catalog["protein_g"] += _f(it.get("fddb_protein_g"))
|
||||
catalog["fat_g"] += _f(it.get("fddb_fat_g"))
|
||||
catalog["carbs_g"] += _f(it.get("fddb_carbs_g"))
|
||||
origin = "mixed"
|
||||
if used_bls and not used_fddb:
|
||||
origin = "bls"
|
||||
elif used_fddb and not used_bls:
|
||||
origin = "fddb"
|
||||
if not items:
|
||||
origin = "manual"
|
||||
return {
|
||||
"existing": existing_macros,
|
||||
"fddb": _round_macros(fddb) if items else None,
|
||||
"catalog": _round_macros(catalog) if items else None,
|
||||
"mapped_item_count": mapped,
|
||||
"unmapped_item_count": unmapped,
|
||||
"has_items": bool(items),
|
||||
"catalog_origin": origin,
|
||||
"has_log": existing is not None,
|
||||
"macro_origin": existing["macro_origin"] if existing else None,
|
||||
}
|
||||
|
||||
|
||||
def apply_nutrition_day_macros(
|
||||
cur,
|
||||
profile_id: str,
|
||||
day: date | str,
|
||||
macros: dict[str, float],
|
||||
*,
|
||||
macro_origin: str,
|
||||
source: str = "csv",
|
||||
confirm: bool = False,
|
||||
) -> str:
|
||||
m = _round_macros(macros)
|
||||
cur.execute("SELECT id FROM nutrition_log WHERE profile_id = %s AND date = %s", (profile_id, day))
|
||||
row = cur.fetchone()
|
||||
counts = compute_day_macro_sums(cur, profile_id, day)
|
||||
extra = (
|
||||
counts["mapped_item_count"],
|
||||
counts["unmapped_item_count"],
|
||||
counts["has_items"],
|
||||
)
|
||||
confirmed = datetime.utcnow() if confirm else None
|
||||
if row:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE nutrition_log
|
||||
SET kcal=%s, protein_g=%s, fat_g=%s, carbs_g=%s, source=%s,
|
||||
macro_origin=%s, mapped_item_count=%s, unmapped_item_count=%s,
|
||||
has_items=%s, last_import_at=NOW(), macros_confirmed_at=COALESCE(%s, macros_confirmed_at)
|
||||
WHERE profile_id=%s AND date=%s
|
||||
""",
|
||||
(
|
||||
m["kcal"], m["protein_g"], m["fat_g"], m["carbs_g"], source,
|
||||
macro_origin, extra[0], extra[1], extra[2], confirmed, profile_id, day,
|
||||
),
|
||||
)
|
||||
return "updated"
|
||||
eid = str(uuid.uuid4())
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO nutrition_log (
|
||||
id, profile_id, date, kcal, protein_g, fat_g, carbs_g, source,
|
||||
macro_origin, mapped_item_count, unmapped_item_count, has_items,
|
||||
last_import_at, macros_confirmed_at, created
|
||||
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,NOW(),%s,CURRENT_TIMESTAMP)
|
||||
""",
|
||||
(
|
||||
eid, profile_id, day, m["kcal"], m["protein_g"], m["fat_g"], m["carbs_g"], source,
|
||||
macro_origin, extra[0], extra[1], extra[2], confirmed,
|
||||
),
|
||||
)
|
||||
return "created"
|
||||
|
||||
|
||||
def rebuild_daily_nutrients(cur, profile_id: str, day: date | str) -> None:
|
||||
cur.execute(
|
||||
"DELETE FROM nutrition_daily_nutrients WHERE profile_id = %s AND date = %s",
|
||||
(profile_id, day),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT i.food_id, i.quantity_g
|
||||
FROM nutrition_items i
|
||||
WHERE i.profile_id = %s AND i.date = %s
|
||||
AND i.food_id IS NOT NULL AND i.quantity_g IS NOT NULL AND i.quantity_g > 0
|
||||
""",
|
||||
(profile_id, day),
|
||||
)
|
||||
acc: dict[int, list[float]] = {}
|
||||
for it in cur.fetchall():
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT v.attribute_id, v.value_num, v.is_trace, a.data_type
|
||||
FROM food_attribute_values v
|
||||
JOIN food_attributes a ON a.id = v.attribute_id
|
||||
WHERE v.food_id = %s AND a.data_type = 'num_per_100g'
|
||||
AND v.value_num IS NOT NULL AND v.is_trace = false
|
||||
""",
|
||||
(it["food_id"],),
|
||||
)
|
||||
factor = float(it["quantity_g"]) / 100.0
|
||||
for row in cur.fetchall():
|
||||
acc.setdefault(row["attribute_id"], []).append(float(row["value_num"]) * factor)
|
||||
for attr_id, vals in acc.items():
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO nutrition_daily_nutrients
|
||||
(profile_id, date, attribute_id, value, contributing_item_count, updated_at)
|
||||
VALUES (%s, %s, %s, %s, %s, NOW())
|
||||
""",
|
||||
(profile_id, day, attr_id, round(sum(vals), 6), len(vals)),
|
||||
)
|
||||
|
||||
|
||||
def _item_value_origin(mapping: dict | None) -> str:
|
||||
if not mapping:
|
||||
return "fddb"
|
||||
kind = mapping.get("catalog_kind")
|
||||
if kind == "official_bls":
|
||||
return "bls"
|
||||
if kind in ("manual_admin", "manual_user"):
|
||||
return "manual_catalog"
|
||||
return "fddb"
|
||||
|
||||
|
||||
def replace_csv_items_for_dates(
|
||||
cur,
|
||||
profile_id: str,
|
||||
rows: list[dict[str, Any]],
|
||||
*,
|
||||
policy: str,
|
||||
policy_override: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Replace csv-sourced items for the dates present in rows.
|
||||
Returns conflicts when policy is prompt and existing macros differ.
|
||||
"""
|
||||
effective = policy_override if policy_override in POLICIES else policy
|
||||
by_date: dict[str, list[dict]] = {}
|
||||
for row in rows:
|
||||
d = row.get("date")
|
||||
if hasattr(d, "isoformat"):
|
||||
iso = d.isoformat()
|
||||
else:
|
||||
iso = str(d)[:10]
|
||||
if not iso:
|
||||
continue
|
||||
by_date.setdefault(iso, []).append(row)
|
||||
|
||||
conflicts = []
|
||||
days_written = 0
|
||||
items_written = 0
|
||||
new_log_days = 0
|
||||
|
||||
for iso, day_rows in by_date.items():
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM nutrition_items
|
||||
WHERE profile_id = %s AND date = %s AND source = 'csv'
|
||||
""",
|
||||
(profile_id, iso),
|
||||
)
|
||||
for raw in day_rows:
|
||||
name = (raw.get("food_name") or raw.get("source_name_raw") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
qty_raw = raw.get("quantity_raw")
|
||||
qty_g = parse_quantity_g(qty_raw if qty_raw is not None else name)
|
||||
mapping = get_food_mapping_with_cursor(cur, name, profile_id)
|
||||
logged_at = raw.get("logged_at")
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO nutrition_items (
|
||||
id, profile_id, date, logged_at, source_name_raw, source_name_normalized,
|
||||
source_system, quantity_raw, quantity_g,
|
||||
fddb_kcal, fddb_protein_g, fddb_fat_g, fddb_carbs_g,
|
||||
food_id, mapping_id, value_origin, source
|
||||
) VALUES (
|
||||
%s,%s,%s,%s,%s,%s,'fddb',%s,%s,%s,%s,%s,%s,%s,%s,%s,'csv'
|
||||
)
|
||||
""",
|
||||
(
|
||||
str(uuid.uuid4()),
|
||||
profile_id,
|
||||
iso,
|
||||
logged_at,
|
||||
name,
|
||||
normalize_food_name(name),
|
||||
str(qty_raw) if qty_raw is not None else None,
|
||||
qty_g,
|
||||
_f(raw.get("fddb_kcal") if raw.get("fddb_kcal") is not None else raw.get("kcal")),
|
||||
_f(raw.get("fddb_protein_g") if raw.get("fddb_protein_g") is not None else raw.get("protein_g")),
|
||||
_f(raw.get("fddb_fat_g") if raw.get("fddb_fat_g") is not None else raw.get("fat_g")),
|
||||
_f(raw.get("fddb_carbs_g") if raw.get("fddb_carbs_g") is not None else raw.get("carbs_g")),
|
||||
mapping["food_id"] if mapping else None,
|
||||
mapping["mapping_id"] if mapping else None,
|
||||
_item_value_origin(mapping),
|
||||
),
|
||||
)
|
||||
items_written += 1
|
||||
days_written += 1
|
||||
sums = compute_day_macro_sums(cur, profile_id, iso)
|
||||
rebuild_daily_nutrients(cur, profile_id, iso)
|
||||
if not sums["has_items"]:
|
||||
continue
|
||||
if not sums["has_log"]:
|
||||
apply_nutrition_day_macros(
|
||||
cur, profile_id, iso, sums["catalog"],
|
||||
macro_origin=sums["catalog_origin"], source="csv",
|
||||
)
|
||||
new_log_days += 1
|
||||
continue
|
||||
existing = sums["existing"]
|
||||
catalog = sums["catalog"]
|
||||
fddb = sums["fddb"]
|
||||
differ = macros_differ(existing, catalog) or macros_differ(existing, fddb)
|
||||
if effective == "keep_existing":
|
||||
_touch_item_counts(cur, profile_id, iso, sums)
|
||||
continue
|
||||
if effective == "overwrite_catalog":
|
||||
apply_nutrition_day_macros(
|
||||
cur, profile_id, iso, catalog,
|
||||
macro_origin=sums["catalog_origin"], source="csv",
|
||||
)
|
||||
continue
|
||||
if effective == "overwrite_fddb":
|
||||
apply_nutrition_day_macros(
|
||||
cur, profile_id, iso, fddb, macro_origin="fddb", source="csv",
|
||||
)
|
||||
continue
|
||||
# prompt
|
||||
if differ:
|
||||
conflicts.append({
|
||||
"date": iso,
|
||||
"existing": existing,
|
||||
"fddb": fddb,
|
||||
"catalog": catalog,
|
||||
"catalog_origin": sums["catalog_origin"],
|
||||
"mapped_item_count": sums["mapped_item_count"],
|
||||
"unmapped_item_count": sums["unmapped_item_count"],
|
||||
})
|
||||
else:
|
||||
apply_nutrition_day_macros(
|
||||
cur, profile_id, iso, catalog,
|
||||
macro_origin=sums["catalog_origin"], source="csv",
|
||||
)
|
||||
|
||||
return {
|
||||
"days_written": days_written,
|
||||
"items_written": items_written,
|
||||
"new_log_days": new_log_days,
|
||||
"conflicts": conflicts,
|
||||
"policy": effective,
|
||||
}
|
||||
|
||||
|
||||
def _touch_item_counts(cur, profile_id: str, day: str, sums: dict) -> None:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE nutrition_log
|
||||
SET mapped_item_count=%s, unmapped_item_count=%s, has_items=%s, last_import_at=NOW()
|
||||
WHERE profile_id=%s AND date=%s
|
||||
""",
|
||||
(
|
||||
sums["mapped_item_count"],
|
||||
sums["unmapped_item_count"],
|
||||
sums["has_items"],
|
||||
profile_id,
|
||||
day,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def resolve_choice_macros(sums: dict[str, Any], choice: str) -> tuple[dict[str, float], str]:
|
||||
if choice == "existing":
|
||||
return sums["existing"], "user_confirmed"
|
||||
if choice == "fddb":
|
||||
return sums["fddb"], "fddb"
|
||||
if choice == "catalog":
|
||||
return sums["catalog"], sums.get("catalog_origin") or "mixed"
|
||||
raise ValueError("Ungültige Wahl (existing|fddb|catalog)")
|
||||
|
||||
|
||||
def resolve_food_attributes(cur, food_id: str) -> list[dict]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT a.attr_key, a.name_de, a.unit, a.category, a.data_type, a.origin AS attr_origin,
|
||||
v.value_num, v.value_bool, v.value_text, v.is_trace, v.origin_code
|
||||
FROM food_attributes a
|
||||
LEFT JOIN food_attribute_values v
|
||||
ON v.attribute_id = a.id AND v.food_id = %s
|
||||
WHERE a.is_active = true
|
||||
ORDER BY a.sort_order, a.attr_key
|
||||
""",
|
||||
(food_id,),
|
||||
)
|
||||
return [dict(r) for r in cur.fetchall()]
|
||||
|
||||
|
||||
def dates_for_normalized_name(cur, profile_id: str, source_name_normalized: str) -> list[str]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT DISTINCT date::text AS date
|
||||
FROM nutrition_items
|
||||
WHERE profile_id = %s AND source_name_normalized = %s
|
||||
""",
|
||||
(profile_id, source_name_normalized),
|
||||
)
|
||||
return [r["date"] for r in cur.fetchall()]
|
||||
|
|
@ -38,6 +38,7 @@ from routers import app_dashboard # Geschützter App-Bereich: Dashboard-Layout
|
|||
from routers import reports # Strukturierter PDF-Bericht (Profil v1)
|
||||
from routers import csv_import, admin_csv_templates # Issue #21 Universal CSV Parser
|
||||
from routers import admin_training_parameters, admin_activity_attribute_profiles # EAV session metrics
|
||||
from routers import bls, admin_bls, admin_food_mappings # BLS catalog + FDDB mapping
|
||||
|
||||
# ── App Configuration ─────────────────────────────────────────────────────────
|
||||
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
|
||||
|
|
@ -133,6 +134,9 @@ app.include_router(csv_import.router) # /api/csv/* (Issue #21)
|
|||
app.include_router(admin_csv_templates.router) # /api/admin/csv-templates/* (Issue #21)
|
||||
app.include_router(admin_training_parameters.router) # /api/admin/training-parameters
|
||||
app.include_router(admin_activity_attribute_profiles.router) # /api/admin/training-*-parameters
|
||||
app.include_router(bls.router) # /api/bls/*
|
||||
app.include_router(admin_bls.router) # /api/admin/bls/*
|
||||
app.include_router(admin_food_mappings.router) # /api/admin/food-mappings
|
||||
|
||||
# ── Health Check ──────────────────────────────────────────────────────────────
|
||||
@app.get("/")
|
||||
|
|
|
|||
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 $$;
|
||||
|
|
@ -30,6 +30,7 @@ class ProfileUpdate(BaseModel):
|
|||
goal_bf_pct: Optional[float] = None
|
||||
quality_filter_level: Optional[str] = None # Issue #31: Global quality filter
|
||||
email: Optional[str] = None # Self-service; leer = entfernen; Änderung setzt Verifikation zurück
|
||||
nutrition_import_conflict_policy: Optional[str] = None
|
||||
|
||||
|
||||
# ── Tracking Models ───────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -12,3 +12,4 @@ python-dateutil==2.9.0
|
|||
tzdata>=2024.1 # ZoneInfo (Europe/Berlin) auch unter Windows
|
||||
matplotlib==3.8.4
|
||||
reportlab==4.2.0
|
||||
openpyxl==3.1.5
|
||||
|
|
|
|||
234
backend/routers/admin_bls.py
Normal file
234
backend/routers/admin_bls.py
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
"""Admin BLS catalog import and attribute/food maintenance."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from pydantic import BaseModel
|
||||
|
||||
from auth import require_admin
|
||||
from bls.import_service import upsert_attributes, upsert_foods
|
||||
from bls.parser import parse_components_xlsx, parse_foods_xlsx
|
||||
from db import get_cursor, get_db, r2d
|
||||
|
||||
router = APIRouter(prefix="/api/admin/bls", tags=["admin", "bls"])
|
||||
|
||||
|
||||
class AttributeCreate(BaseModel):
|
||||
attr_key: str
|
||||
name_de: str
|
||||
name_en: Optional[str] = None
|
||||
unit: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
data_type: str = "num_per_100g"
|
||||
enum_values: Optional[list] = None
|
||||
|
||||
|
||||
class ManualFoodCreate(BaseModel):
|
||||
name_de: str
|
||||
name_en: Optional[str] = None
|
||||
macros_per_100g: Optional[dict] = None
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def bls_status(session: dict = Depends(require_admin)):
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT COUNT(*) AS n FROM food_catalog WHERE catalog_kind = 'official_bls'")
|
||||
foods = cur.fetchone()["n"]
|
||||
cur.execute("SELECT COUNT(*) AS n FROM food_attributes WHERE origin = 'official_bls'")
|
||||
attrs = cur.fetchone()["n"]
|
||||
cur.execute("SELECT COUNT(*) AS n FROM food_catalog WHERE catalog_kind <> 'official_bls'")
|
||||
manual = cur.fetchone()["n"]
|
||||
cur.execute("SELECT MAX(updated_at) AS last_updated FROM food_catalog WHERE catalog_kind = 'official_bls'")
|
||||
last = cur.fetchone()["last_updated"]
|
||||
return {
|
||||
"official_foods": foods,
|
||||
"official_attributes": attrs,
|
||||
"manual_foods": manual,
|
||||
"last_updated": last,
|
||||
"source": "Max Rubner-Institut, BLS 4.0 (frei verfügbar)",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/import/components")
|
||||
async def import_components(
|
||||
file: UploadFile = File(...),
|
||||
dry_run: bool = True,
|
||||
session: dict = Depends(require_admin),
|
||||
):
|
||||
raw = await file.read()
|
||||
if not raw:
|
||||
raise HTTPException(400, "Leere Datei")
|
||||
try:
|
||||
attrs = parse_components_xlsx(raw)
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"Components-Datei unlesbar: {e}") from e
|
||||
if dry_run:
|
||||
return {"dry_run": True, "attributes": len(attrs), "sample": attrs[:8]}
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
stats = upsert_attributes(cur, attrs)
|
||||
return {"dry_run": False, **stats}
|
||||
|
||||
|
||||
@router.post("/import/foods")
|
||||
async def import_foods(
|
||||
file: UploadFile = File(...),
|
||||
dry_run: bool = True,
|
||||
session: dict = Depends(require_admin),
|
||||
):
|
||||
raw = await file.read()
|
||||
if not raw:
|
||||
raise HTTPException(400, "Leere Datei")
|
||||
try:
|
||||
parsed = parse_foods_xlsx(raw)
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"Datendatei unlesbar: {e}") from e
|
||||
foods = parsed["foods"]
|
||||
if dry_run:
|
||||
return {
|
||||
"dry_run": True,
|
||||
"foods": len(foods),
|
||||
"attribute_columns": len(parsed["attribute_headers"]),
|
||||
"sample": [
|
||||
{"bls_code": f["bls_code"], "name_de": f["name_de"]}
|
||||
for f in foods[:8]
|
||||
],
|
||||
}
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
stats = upsert_foods(cur, foods)
|
||||
return {"dry_run": False, **stats}
|
||||
|
||||
|
||||
@router.get("/foods")
|
||||
def admin_list_foods(
|
||||
q: Optional[str] = None,
|
||||
kind: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
session: dict = Depends(require_admin),
|
||||
):
|
||||
limit = min(max(limit, 1), 200)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
conds = ["is_active = true"]
|
||||
params: list = []
|
||||
if kind:
|
||||
conds.append("catalog_kind = %s")
|
||||
params.append(kind)
|
||||
if q:
|
||||
conds.append("(name_de ILIKE %s OR COALESCE(name_en,'') ILIKE %s OR COALESCE(bls_code,'') ILIKE %s)")
|
||||
like = f"%{q}%"
|
||||
params.extend([like, like, like])
|
||||
params.append(limit)
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT id, bls_code, name_de, name_en, catalog_kind, food_group, bls_version
|
||||
FROM food_catalog
|
||||
WHERE {' AND '.join(conds)}
|
||||
ORDER BY name_de
|
||||
LIMIT %s
|
||||
""",
|
||||
params,
|
||||
)
|
||||
return [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
|
||||
@router.get("/foods/{food_id}")
|
||||
def admin_food_detail(food_id: str, session: dict = Depends(require_admin)):
|
||||
from data_layer.nutrition_items import resolve_food_attributes
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT * FROM food_catalog WHERE id = %s", (food_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Lebensmittel nicht gefunden")
|
||||
attrs = resolve_food_attributes(cur, food_id)
|
||||
return {**r2d(row), "attributes": attrs}
|
||||
|
||||
|
||||
@router.post("/foods/manual")
|
||||
def admin_create_manual_food(body: ManualFoodCreate, session: dict = Depends(require_admin)):
|
||||
from data_layer.food_mapping import normalize_food_name
|
||||
|
||||
name = (body.name_de or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "Name fehlt")
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO food_catalog (name_de, name_en, catalog_kind, source, external_key)
|
||||
VALUES (%s, %s, 'manual_admin', 'manual', %s)
|
||||
RETURNING *
|
||||
""",
|
||||
(name, body.name_en, f"man-admin-{normalize_food_name(name)[:40]}"),
|
||||
)
|
||||
food = r2d(cur.fetchone())
|
||||
_write_manual_macros(cur, food["id"], body.macros_per_100g)
|
||||
return food
|
||||
|
||||
|
||||
@router.get("/attributes")
|
||||
def admin_list_attributes(session: dict = Depends(require_admin)):
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, attr_key, name_de, name_en, unit, category, data_type, origin, sort_order
|
||||
FROM food_attributes
|
||||
WHERE is_active = true
|
||||
ORDER BY origin, sort_order, attr_key
|
||||
"""
|
||||
)
|
||||
return [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
|
||||
@router.post("/attributes")
|
||||
def admin_create_attribute(body: AttributeCreate, session: dict = Depends(require_admin)):
|
||||
key = body.attr_key.strip().upper().replace(" ", "_")
|
||||
if not key:
|
||||
raise HTTPException(400, "attr_key fehlt")
|
||||
if body.data_type not in ("num_per_100g", "boolean", "text", "enum"):
|
||||
raise HTTPException(400, "Ungültiger data_type")
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO food_attributes
|
||||
(attr_key, name_de, name_en, unit, category, data_type, enum_values, origin, sort_order)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, 'extension', 9000)
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
key, body.name_de, body.name_en, body.unit, body.category,
|
||||
body.data_type, None if not body.enum_values else body.enum_values,
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(409, f"Attribut existiert bereits oder ist ungültig: {e}") from e
|
||||
return r2d(cur.fetchone())
|
||||
|
||||
|
||||
def _write_manual_macros(cur, food_id: str, macros: dict | None) -> None:
|
||||
if not macros:
|
||||
return
|
||||
mapping = {"kcal": "ENERCC", "protein_g": "PROT625", "fat_g": "FAT", "carbs_g": "CHO"}
|
||||
for field, key in mapping.items():
|
||||
if field not in macros or macros[field] is None:
|
||||
continue
|
||||
cur.execute("SELECT id FROM food_attributes WHERE attr_key = %s", (key,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
continue
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO food_attribute_values (food_id, attribute_id, value_num, is_trace)
|
||||
VALUES (%s, %s, %s, false)
|
||||
ON CONFLICT (food_id, attribute_id) DO UPDATE SET value_num = EXCLUDED.value_num, updated_at = NOW()
|
||||
""",
|
||||
(food_id, row["id"], float(macros[field])),
|
||||
)
|
||||
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}
|
||||
146
backend/routers/bls.py
Normal file
146
backend/routers/bls.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
"""Authenticated catalog search and user-owned foods / mappings."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from auth import require_auth
|
||||
from data_layer.food_mapping import (
|
||||
apply_mapping_to_items,
|
||||
clear_mapping_from_items,
|
||||
normalize_food_name,
|
||||
suggest_catalog_foods,
|
||||
upsert_food_mapping,
|
||||
)
|
||||
from data_layer.nutrition_items import dates_for_normalized_name, rebuild_daily_nutrients
|
||||
from db import get_cursor, get_db, r2d
|
||||
from routers.profiles import get_pid
|
||||
|
||||
router = APIRouter(prefix="/api/bls", tags=["bls"])
|
||||
|
||||
|
||||
class UserFoodCreate(BaseModel):
|
||||
name_de: str
|
||||
name_en: Optional[str] = None
|
||||
macros_per_100g: Optional[dict] = None
|
||||
|
||||
|
||||
class MappingUpsert(BaseModel):
|
||||
source_name: str
|
||||
food_id: str
|
||||
source_system: str = "fddb"
|
||||
|
||||
|
||||
@router.get("/foods")
|
||||
def search_foods(
|
||||
q: str = "",
|
||||
limit: int = 20,
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
pid = session["profile_id"]
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
return suggest_catalog_foods(cur, q, pid, limit=min(max(limit, 1), 50))
|
||||
|
||||
|
||||
@router.post("/foods/manual")
|
||||
def create_user_food(body: UserFoodCreate, session: dict = Depends(require_auth)):
|
||||
from routers.admin_bls import _write_manual_macros
|
||||
|
||||
pid = session["profile_id"]
|
||||
name = (body.name_de or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "Name fehlt")
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO food_catalog
|
||||
(name_de, name_en, catalog_kind, owner_profile_id, source, external_key)
|
||||
VALUES (%s, %s, 'manual_user', %s, 'manual', %s)
|
||||
RETURNING *
|
||||
""",
|
||||
(name, body.name_en, pid, f"man-user-{normalize_food_name(name)[:40]}"),
|
||||
)
|
||||
food = r2d(cur.fetchone())
|
||||
_write_manual_macros(cur, food["id"], body.macros_per_100g)
|
||||
return food
|
||||
|
||||
|
||||
@router.get("/mappings")
|
||||
def list_my_mappings(session: dict = Depends(require_auth)):
|
||||
pid = session["profile_id"]
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT m.id, m.source_name_raw, m.source_name_normalized, m.food_id, m.source,
|
||||
f.name_de AS food_name_de, f.bls_code, f.catalog_kind
|
||||
FROM food_name_mappings m
|
||||
JOIN food_catalog f ON f.id = m.food_id
|
||||
WHERE m.profile_id = %s
|
||||
ORDER BY m.source_name_normalized
|
||||
""",
|
||||
(pid,),
|
||||
)
|
||||
return [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
|
||||
@router.post("/mappings")
|
||||
def upsert_my_mapping(body: MappingUpsert, session: dict = Depends(require_auth)):
|
||||
pid = session["profile_id"]
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id FROM food_catalog
|
||||
WHERE id = %s AND is_active = true
|
||||
AND (owner_profile_id IS NULL OR owner_profile_id = %s)
|
||||
""",
|
||||
(body.food_id, pid),
|
||||
)
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(404, "Lebensmittel nicht gefunden")
|
||||
mid = upsert_food_mapping(
|
||||
cur,
|
||||
source_name_raw=body.source_name,
|
||||
food_id=body.food_id,
|
||||
profile_id=pid,
|
||||
source="bulk",
|
||||
source_system=body.source_system,
|
||||
)
|
||||
norm = normalize_food_name(body.source_name)
|
||||
n = apply_mapping_to_items(cur, pid, norm, body.food_id, mid)
|
||||
for d in dates_for_normalized_name(cur, pid, norm):
|
||||
rebuild_daily_nutrients(cur, pid, d)
|
||||
return {"mapping_id": mid, "items_updated": n, "source_name_normalized": norm}
|
||||
|
||||
|
||||
@router.delete("/mappings/{mapping_id}")
|
||||
def delete_my_mapping(mapping_id: int, session: dict = Depends(require_auth)):
|
||||
pid = session["profile_id"]
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT source_name_normalized FROM food_name_mappings
|
||||
WHERE id = %s AND profile_id = %s
|
||||
""",
|
||||
(mapping_id, pid),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Mapping nicht gefunden")
|
||||
norm = row["source_name_normalized"]
|
||||
dates = dates_for_normalized_name(cur, pid, norm)
|
||||
clear_mapping_from_items(cur, pid, norm)
|
||||
cur.execute("DELETE FROM food_name_mappings WHERE id = %s AND profile_id = %s", (mapping_id, pid))
|
||||
for d in dates:
|
||||
rebuild_daily_nutrients(cur, pid, d)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# keep get_pid imported for consistency with other routers
|
||||
_ = get_pid
|
||||
|
|
@ -31,8 +31,13 @@ def _pf(s):
|
|||
|
||||
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
||||
@router.post("/import-csv")
|
||||
async def import_nutrition_csv(file: UploadFile=File(...), x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
"""Import FDDB nutrition CSV."""
|
||||
async def import_nutrition_csv(
|
||||
file: UploadFile = File(...),
|
||||
overwrite_existing: bool = False,
|
||||
x_profile_id: Optional[str] = Header(default=None),
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
"""Import FDDB nutrition CSV (optional item persist + conflict policy)."""
|
||||
pid = get_pid(x_profile_id)
|
||||
|
||||
# Phase 4: Check feature access and ENFORCE
|
||||
|
|
@ -56,48 +61,97 @@ async def import_nutrition_csv(file: UploadFile=File(...), x_profile_id: Optiona
|
|||
except: text = raw.decode('latin-1')
|
||||
if text.startswith('\ufeff'): text = text[1:]
|
||||
if not text.strip(): raise HTTPException(400,"Leere Datei")
|
||||
from data_layer.nutrition_items import get_import_policy, replace_csv_items_for_dates
|
||||
|
||||
overwrite = bool(overwrite_existing)
|
||||
reader = csv.DictReader(io.StringIO(text), delimiter=';')
|
||||
item_rows = []
|
||||
days: dict = {}
|
||||
count = 0
|
||||
for row in reader:
|
||||
rd = row.get('datum_tag_monat_jahr_stunde_minute','').strip().strip('"')
|
||||
if not rd: continue
|
||||
try:
|
||||
p = rd.split(' ')[0].split('.')
|
||||
parts = rd.split(' ')
|
||||
p = parts[0].split('.')
|
||||
iso = f"{p[2]}-{p[1]}-{p[0]}"
|
||||
except: continue
|
||||
days.setdefault(iso,{'kcal':0,'fat_g':0,'carbs_g':0,'protein_g':0})
|
||||
days[iso]['kcal'] += _pf(row.get('kj',0))/4.184
|
||||
days[iso]['fat_g'] += _pf(row.get('fett_g',0))
|
||||
days[iso]['carbs_g'] += _pf(row.get('kh_g',0))
|
||||
days[iso]['protein_g'] += _pf(row.get('protein_g',0))
|
||||
count+=1
|
||||
inserted=0
|
||||
new_entries=0
|
||||
logged_at = None
|
||||
if len(parts) > 1:
|
||||
try:
|
||||
logged_at = datetime.strptime(rd.strip(), '%d.%m.%Y %H:%M')
|
||||
except ValueError:
|
||||
logged_at = None
|
||||
except Exception:
|
||||
continue
|
||||
kcal = _pf(row.get('kj', 0)) / 4.184
|
||||
fat = _pf(row.get('fett_g', 0))
|
||||
carbs = _pf(row.get('kh_g', 0))
|
||||
prot = _pf(row.get('protein_g', 0))
|
||||
days.setdefault(iso, {'kcal': 0, 'fat_g': 0, 'carbs_g': 0, 'protein_g': 0})
|
||||
days[iso]['kcal'] += kcal
|
||||
days[iso]['fat_g'] += fat
|
||||
days[iso]['carbs_g'] += carbs
|
||||
days[iso]['protein_g'] += prot
|
||||
name = (row.get('bezeichnung') or '').strip().strip('"')
|
||||
if name:
|
||||
item_rows.append({
|
||||
"date": iso,
|
||||
"logged_at": logged_at,
|
||||
"food_name": name,
|
||||
"quantity_raw": (row.get('menge') or '').strip() or None,
|
||||
"kcal": kcal,
|
||||
"protein_g": prot,
|
||||
"fat_g": fat,
|
||||
"carbs_g": carbs,
|
||||
})
|
||||
count += 1
|
||||
inserted = 0
|
||||
new_entries = 0
|
||||
ingest_result = {"conflicts": [], "items_written": 0, "policy": "prompt"}
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
for iso,vals in days.items():
|
||||
kcal=round(vals['kcal'],1); fat=round(vals['fat_g'],1)
|
||||
carbs=round(vals['carbs_g'],1); prot=round(vals['protein_g'],1)
|
||||
cur.execute("SELECT id FROM nutrition_log WHERE profile_id=%s AND date=%s",(pid,iso))
|
||||
policy = get_import_policy(cur, pid)
|
||||
override = "overwrite_catalog" if overwrite else None
|
||||
if item_rows:
|
||||
ingest_result = replace_csv_items_for_dates(
|
||||
cur, pid, item_rows, policy=policy, policy_override=override,
|
||||
)
|
||||
new_entries = ingest_result.get("new_log_days") or 0
|
||||
inserted = ingest_result.get("days_written") or 0
|
||||
else:
|
||||
for iso, vals in days.items():
|
||||
kcal = round(vals['kcal'], 1)
|
||||
fat = round(vals['fat_g'], 1)
|
||||
carbs = round(vals['carbs_g'], 1)
|
||||
prot = round(vals['protein_g'], 1)
|
||||
cur.execute("SELECT id FROM nutrition_log WHERE profile_id=%s AND date=%s", (pid, iso))
|
||||
is_new = not cur.fetchone()
|
||||
if not is_new:
|
||||
# UPDATE existing
|
||||
cur.execute("UPDATE nutrition_log SET kcal=%s,protein_g=%s,fat_g=%s,carbs_g=%s WHERE profile_id=%s AND date=%s",
|
||||
(kcal,prot,fat,carbs,pid,iso))
|
||||
if policy in ("overwrite_catalog", "overwrite_fddb") or overwrite:
|
||||
cur.execute(
|
||||
"UPDATE nutrition_log SET kcal=%s,protein_g=%s,fat_g=%s,carbs_g=%s,source='csv',macro_origin='fddb' WHERE profile_id=%s AND date=%s",
|
||||
(kcal, prot, fat, carbs, pid, iso),
|
||||
)
|
||||
else:
|
||||
# INSERT new
|
||||
cur.execute("INSERT INTO nutrition_log (id,profile_id,date,kcal,protein_g,fat_g,carbs_g,source,created) VALUES (%s,%s,%s,%s,%s,%s,%s,'csv',CURRENT_TIMESTAMP)",
|
||||
(str(uuid.uuid4()),pid,iso,kcal,prot,fat,carbs))
|
||||
cur.execute(
|
||||
"INSERT INTO nutrition_log (id,profile_id,date,kcal,protein_g,fat_g,carbs_g,source,macro_origin,created) VALUES (%s,%s,%s,%s,%s,%s,%s,'csv','fddb',CURRENT_TIMESTAMP)",
|
||||
(str(uuid.uuid4()), pid, iso, kcal, prot, fat, carbs),
|
||||
)
|
||||
new_entries += 1
|
||||
inserted+=1
|
||||
inserted += 1
|
||||
|
||||
# Phase 2: Increment usage counter for each new entry created
|
||||
for _ in range(new_entries):
|
||||
increment_feature_usage(pid, 'nutrition_entries')
|
||||
|
||||
return {"rows_parsed":count,"days_imported":inserted,"new_entries":new_entries,
|
||||
"date_range":{"from":min(days) if days else None,"to":max(days) if days else None}}
|
||||
return {
|
||||
"rows_parsed": count,
|
||||
"days_imported": inserted,
|
||||
"new_entries": new_entries,
|
||||
"items_written": ingest_result.get("items_written", 0),
|
||||
"conflicts": ingest_result.get("conflicts") or [],
|
||||
"policy": ingest_result.get("policy", "prompt"),
|
||||
"date_range": {"from": min(days) if days else None, "to": max(days) if days else None},
|
||||
}
|
||||
|
||||
|
||||
@router.post("")
|
||||
|
|
@ -122,7 +176,7 @@ def create_nutrition(date: str, kcal: float, protein_g: float, fat_g: float, car
|
|||
# UPDATE existing entry
|
||||
cur.execute("""
|
||||
UPDATE nutrition_log
|
||||
SET kcal=%s, protein_g=%s, fat_g=%s, carbs_g=%s, source='manual'
|
||||
SET kcal=%s, protein_g=%s, fat_g=%s, carbs_g=%s, source='manual', macro_origin='manual'
|
||||
WHERE id=%s AND profile_id=%s
|
||||
""", (round(kcal,1), round(protein_g,1), round(fat_g,1), round(carbs_g,1), existing['id'], pid))
|
||||
return {"success": True, "mode": "updated", "id": existing['id']}
|
||||
|
|
@ -145,8 +199,8 @@ def create_nutrition(date: str, kcal: float, protein_g: float, fat_g: float, car
|
|||
# INSERT new entry
|
||||
new_id = str(uuid.uuid4())
|
||||
cur.execute("""
|
||||
INSERT INTO nutrition_log (id, profile_id, date, kcal, protein_g, fat_g, carbs_g, source, created)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, 'manual', CURRENT_TIMESTAMP)
|
||||
INSERT INTO nutrition_log (id, profile_id, date, kcal, protein_g, fat_g, carbs_g, source, macro_origin, created)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, 'manual', 'manual', CURRENT_TIMESTAMP)
|
||||
""", (new_id, pid, date, round(kcal,1), round(protein_g,1), round(fat_g,1), round(carbs_g,1)))
|
||||
|
||||
# Phase 2: Increment usage counter
|
||||
|
|
@ -162,7 +216,17 @@ def list_nutrition(limit: int=365, x_profile_id: Optional[str]=Header(default=No
|
|||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date DESC LIMIT %s", (pid,limit))
|
||||
"""
|
||||
SELECT n.*, m.mark_type, m.note AS mark_note
|
||||
FROM nutrition_log n
|
||||
LEFT JOIN nutrition_day_marks m
|
||||
ON m.profile_id = n.profile_id AND m.date = n.date
|
||||
WHERE n.profile_id=%s
|
||||
ORDER BY n.date DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(pid, limit),
|
||||
)
|
||||
return [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
|
||||
|
|
@ -228,6 +292,155 @@ def import_history(x_profile_id: Optional[str]=Header(default=None), session: di
|
|||
return [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
|
||||
@router.get("/items")
|
||||
def list_nutrition_items(
|
||||
date: Optional[str] = None,
|
||||
limit: int = 200,
|
||||
x_profile_id: Optional[str] = Header(default=None),
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
pid = get_pid(x_profile_id)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
if date:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT i.*, f.name_de AS food_name_de, f.bls_code, f.catalog_kind
|
||||
FROM nutrition_items i
|
||||
LEFT JOIN food_catalog f ON f.id = i.food_id
|
||||
WHERE i.profile_id=%s AND i.date=%s
|
||||
ORDER BY i.logged_at NULLS LAST, i.source_name_raw
|
||||
""",
|
||||
(pid, date),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT i.*, f.name_de AS food_name_de, f.bls_code, f.catalog_kind
|
||||
FROM nutrition_items i
|
||||
LEFT JOIN food_catalog f ON f.id = i.food_id
|
||||
WHERE i.profile_id=%s
|
||||
ORDER BY i.date DESC, i.logged_at NULLS LAST
|
||||
LIMIT %s
|
||||
""",
|
||||
(pid, min(limit, 500)),
|
||||
)
|
||||
return [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
|
||||
@router.get("/unmapped")
|
||||
def list_unmapped_foods(
|
||||
x_profile_id: Optional[str] = Header(default=None),
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
pid = get_pid(x_profile_id)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT source_name_raw, source_name_normalized,
|
||||
COUNT(*) AS count, MIN(date) AS first_date, MAX(date) AS last_date
|
||||
FROM nutrition_items
|
||||
WHERE profile_id=%s AND food_id IS NULL
|
||||
GROUP BY source_name_raw, source_name_normalized
|
||||
ORDER BY count DESC, source_name_normalized
|
||||
""",
|
||||
(pid,),
|
||||
)
|
||||
return [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
|
||||
@router.post("/import-conflicts/resolve")
|
||||
def resolve_import_conflicts(
|
||||
body: dict,
|
||||
x_profile_id: Optional[str] = Header(default=None),
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
from data_layer.nutrition_items import (
|
||||
apply_nutrition_day_macros,
|
||||
compute_day_macro_sums,
|
||||
resolve_choice_macros,
|
||||
)
|
||||
|
||||
pid = get_pid(x_profile_id)
|
||||
decisions = body.get("decisions") or []
|
||||
applied = 0
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
for dec in decisions:
|
||||
day = dec.get("date")
|
||||
choice = dec.get("choice")
|
||||
if not day or not choice:
|
||||
continue
|
||||
sums = compute_day_macro_sums(cur, pid, day)
|
||||
if not sums.get("existing") and choice == "existing":
|
||||
continue
|
||||
macros, origin = resolve_choice_macros(sums, choice)
|
||||
apply_nutrition_day_macros(
|
||||
cur, pid, day, macros, macro_origin=origin, source="csv", confirm=True,
|
||||
)
|
||||
applied += 1
|
||||
return {"applied": applied}
|
||||
|
||||
|
||||
@router.put("/days/{day}/mark")
|
||||
def upsert_day_mark(
|
||||
day: str,
|
||||
body: dict,
|
||||
x_profile_id: Optional[str] = Header(default=None),
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
pid = get_pid(x_profile_id)
|
||||
mark_type = (body or {}).get("mark_type")
|
||||
note = (body or {}).get("note")
|
||||
if mark_type not in ("fasting", "incomplete"):
|
||||
raise HTTPException(400, "mark_type muss fasting oder incomplete sein")
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO nutrition_day_marks (profile_id, date, mark_type, note, source, updated_at)
|
||||
VALUES (%s, %s, %s, %s, 'manual', NOW())
|
||||
ON CONFLICT (profile_id, date)
|
||||
DO UPDATE SET mark_type = EXCLUDED.mark_type, note = EXCLUDED.note, updated_at = NOW()
|
||||
RETURNING *
|
||||
""",
|
||||
(pid, day, mark_type, note),
|
||||
)
|
||||
return r2d(cur.fetchone())
|
||||
|
||||
|
||||
@router.delete("/days/{day}/mark")
|
||||
def delete_day_mark(
|
||||
day: str,
|
||||
x_profile_id: Optional[str] = Header(default=None),
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
pid = get_pid(x_profile_id)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"DELETE FROM nutrition_day_marks WHERE profile_id=%s AND date=%s",
|
||||
(pid, day),
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/marks")
|
||||
def list_day_marks(
|
||||
x_profile_id: Optional[str] = Header(default=None),
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
pid = get_pid(x_profile_id)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"SELECT * FROM nutrition_day_marks WHERE profile_id=%s ORDER BY date DESC",
|
||||
(pid,),
|
||||
)
|
||||
return [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
|
||||
@router.put("/{entry_id}")
|
||||
def update_nutrition(entry_id: str, kcal: float, protein_g: float, fat_g: float, carbs_g: float,
|
||||
x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
|
|
@ -242,7 +455,7 @@ def update_nutrition(entry_id: str, kcal: float, protein_g: float, fat_g: float,
|
|||
|
||||
cur.execute("""
|
||||
UPDATE nutrition_log
|
||||
SET kcal=%s, protein_g=%s, fat_g=%s, carbs_g=%s
|
||||
SET kcal=%s, protein_g=%s, fat_g=%s, carbs_g=%s, source='manual', macro_origin='manual'
|
||||
WHERE id=%s AND profile_id=%s
|
||||
""", (round(kcal,1), round(protein_g,1), round(fat_g,1), round(carbs_g,1), entry_id, pid))
|
||||
|
||||
|
|
|
|||
|
|
@ -109,9 +109,17 @@ def update_profile(pid: str, p: ProfileUpdate, session=Depends(require_auth)):
|
|||
data["verification_expires"] = None
|
||||
|
||||
nullable_keys = {"goal_weight", "goal_bf_pct", "dob"}
|
||||
allowed_nutrition_policy = {
|
||||
"prompt", "overwrite_catalog", "overwrite_fddb", "keep_existing",
|
||||
}
|
||||
for k, v in patch.items():
|
||||
if k == "email":
|
||||
continue
|
||||
if k == "nutrition_import_conflict_policy":
|
||||
if v not in allowed_nutrition_policy:
|
||||
raise HTTPException(400, "Ungültige Import-Policy")
|
||||
data[k] = v
|
||||
continue
|
||||
if v is None and k in nullable_keys:
|
||||
data[k] = None
|
||||
elif v is not None:
|
||||
|
|
|
|||
39
backend/tests/test_bls_parser.py
Normal file
39
backend/tests/test_bls_parser.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
from io import BytesIO
|
||||
|
||||
from openpyxl import Workbook
|
||||
|
||||
from bls.parser import parse_components_xlsx, parse_foods_xlsx
|
||||
|
||||
|
||||
def _xlsx(rows):
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
for row in rows:
|
||||
ws.append(row)
|
||||
buf = BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_parse_components_dynamic():
|
||||
data = _xlsx([
|
||||
["Code", "Name_DE", "Name_EN", "Einheit"],
|
||||
["ENERCC", "Energie", "Energy", "kcal/100g"],
|
||||
["NA", "Natrium", "Sodium", "mg/100g"],
|
||||
])
|
||||
attrs = parse_components_xlsx(data)
|
||||
keys = {a["attr_key"] for a in attrs}
|
||||
assert "ENERCC" in keys
|
||||
assert "NA" in keys
|
||||
|
||||
|
||||
def test_parse_foods_keeps_bls_code():
|
||||
data = _xlsx([
|
||||
["BLS Code", "Name", "Food name", "ENERCC Energie [kcal/100g]", "ENERCC Herkunft", "ENERCC Referenz"],
|
||||
["C131000", "Hafer roh", "Oats raw", 350, "Analyse", "MRI"],
|
||||
])
|
||||
parsed = parse_foods_xlsx(data)
|
||||
assert parsed["foods"][0]["bls_code"] == "C131000"
|
||||
assert parsed["foods"][0]["name_de"] == "Hafer roh"
|
||||
vals = {v["attr_key"]: v["value_num"] for v in parsed["foods"][0]["values"]}
|
||||
assert vals.get("ENERCC") == 350
|
||||
18
backend/tests/test_food_mapping.py
Normal file
18
backend/tests/test_food_mapping.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
from data_layer.food_mapping import normalize_food_name, parse_quantity_g
|
||||
from data_layer.nutrition_items import macros_differ
|
||||
|
||||
|
||||
def test_normalize_strips_leading_quantity():
|
||||
assert normalize_food_name("50 g Hähnchen") == "hähnchen"
|
||||
assert normalize_food_name(" Vollmilch 3,5% ") == "vollmilch 3.5%"
|
||||
|
||||
|
||||
def test_parse_quantity_g():
|
||||
assert parse_quantity_g("150 g") == 150.0
|
||||
assert parse_quantity_g("150") == 150.0
|
||||
assert parse_quantity_g("1 Stück") is None
|
||||
|
||||
|
||||
def test_macros_differ_rounds():
|
||||
assert not macros_differ({"kcal": 1.04, "protein_g": 0, "fat_g": 0, "carbs_g": 0}, {"kcal": 1.0, "protein_g": 0, "fat_g": 0, "carbs_g": 0})
|
||||
assert macros_differ({"kcal": 10, "protein_g": 0, "fat_g": 0, "carbs_g": 0}, {"kcal": 11, "protein_g": 0, "fat_g": 0, "carbs_g": 0})
|
||||
|
|
@ -7,9 +7,9 @@ Semantic Versioning: MAJOR.MINOR.PATCH
|
|||
- PATCH: Bugfix, kleine Änderung, Refactor
|
||||
"""
|
||||
|
||||
APP_VERSION = "0.9u"
|
||||
BUILD_DATE = "2026-07-24"
|
||||
DB_SCHEMA_VERSION = "20260409c" # 048/049 vitals_baseline.source csv + SAVEPOINT Import
|
||||
APP_VERSION = "0.9v"
|
||||
BUILD_DATE = "2026-09-12"
|
||||
DB_SCHEMA_VERSION = "20260912" # 062 BLS catalog + nutrition items/marks
|
||||
|
||||
MODULE_VERSIONS = {
|
||||
"auth": "1.2.0",
|
||||
|
|
@ -20,7 +20,8 @@ MODULE_VERSIONS = {
|
|||
"circumference": "1.0.1",
|
||||
"caliper": "1.0.1",
|
||||
"activity": "1.2.1", # Legacy CSV import: activity_entries feature enforcement
|
||||
"nutrition": "1.0.3", # FDDB import UI: UsageBadge + blocked import zone
|
||||
"nutrition": "1.1.0", # BLS mapping, items, day marks, import policy
|
||||
"bls": "1.0.0",
|
||||
"photos": "1.0.0",
|
||||
"insights": "1.3.0",
|
||||
"prompts": "1.1.0",
|
||||
|
|
@ -36,6 +37,15 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.9v",
|
||||
"date": "2026-09-12",
|
||||
"changes": [
|
||||
"BLS 4.0 Stammdaten (dynamische Attribute, Upsert über bls_code)",
|
||||
"Lernendes FDDB-Mapping ohne KI, änder- und löschbar",
|
||||
"Optionale nutrition_items, Import-Policy, Fasten-/Lücken-Marken",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.9u",
|
||||
"date": "2026-07-24",
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
|---------|--------|
|
||||
| Hauptnavigation | `frontend/src/config/appNav.js` (`getMainNavItems`) |
|
||||
| Active-State Bottom + Sidebar | `frontend/src/App.jsx` (`navItemActive`), `frontend/src/components/DesktopSidebar.jsx` |
|
||||
| Admin Shell & Routing | `frontend/src/layouts/AdminShell.jsx`, `frontend/src/layouts/RequireAdmin.jsx`, `frontend/src/config/adminNav.js` (`ADMIN_GROUPS`, Hubs) |
|
||||
| Admin Shell & Routing | `frontend/src/layouts/AdminShell.jsx`, `frontend/src/layouts/RequireAdmin.jsx`, `frontend/src/config/adminNav.js` (`ADMIN_GROUPS`, Hubs; Gruppe **Ernährung**: BLS-Import, Katalog, Attribute, FDDB-Mappings) |
|
||||
| Admin-Seiten | `frontend/src/pages/AdminHomePage.jsx`, `AdminGroupHubPage.jsx`, `AdminUsersPage.jsx`, `AdminSystemPage.jsx`, … |
|
||||
| Einstellungen Profil | `frontend/src/pages/SettingsPage.jsx`, `.settings-page__field` in `app.css` |
|
||||
| KI-Analyse Layout | `frontend/src/pages/Analysis.jsx` + `.analysis-split*` in `app.css` |
|
||||
|
|
|
|||
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
|
||||
16
docs/issues/issue-bls-food-mapping.md
Normal file
16
docs/issues/issue-bls-food-mapping.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# BLS-Stammdaten, FDDB-Mapping, Item-Tagebuch
|
||||
|
||||
**Status:** in Umsetzung · **Gitea:** [#106](http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/106) · **Folge:** #75 (Zucker/Ballaststoffe/Qualität)
|
||||
|
||||
## Ziel
|
||||
|
||||
Verlässliche Lebensmittel-Stammdaten (BLS 4.0 + manuelle Erweiterung), lernendes Mapping aus FDDB, optionale Item-Ebene. Tagesmakros bleiben ohne Katalog nutzbar.
|
||||
|
||||
## Abnahme Phase 1
|
||||
|
||||
- BLS-XLSX-Import (Dry-Run + Apply), Codes bleiben erhalten
|
||||
- Manuelle Foods gekennzeichnet
|
||||
- Mapping: Lookup, Bestätigen, Ändern, Löschen; keine KI
|
||||
- FDDB-Import speichert Zeilen; Makro-Konflikt laut Policy
|
||||
- Fasten-/Lücken-Marken unabhängig vom Import
|
||||
- Einzelerfassung nur Makros unverändert
|
||||
|
|
@ -57,6 +57,14 @@ import CustomGoalsPage from './pages/CustomGoalsPage'
|
|||
import UniversalCsvImportPage from './pages/UniversalCsvImportPage'
|
||||
import AdminCsvTemplatesPage from './pages/AdminCsvTemplatesPage'
|
||||
import AdminCsvTemplateEditorPage from './pages/AdminCsvTemplateEditorPage'
|
||||
import AdminBlsImportPage from './pages/AdminBlsImportPage'
|
||||
import AdminBlsFoodsPage from './pages/AdminBlsFoodsPage'
|
||||
import AdminFoodMappingsPage from './pages/AdminFoodMappingsPage'
|
||||
import AdminFoodAttributesPage from './pages/AdminFoodAttributesPage'
|
||||
import AdminBlsImportPage from './pages/AdminBlsImportPage'
|
||||
import AdminBlsFoodsPage from './pages/AdminBlsFoodsPage'
|
||||
import AdminFoodMappingsPage from './pages/AdminFoodMappingsPage'
|
||||
import AdminFoodAttributesPage from './pages/AdminFoodAttributesPage'
|
||||
import WorkflowEditorPage from './pages/WorkflowEditorPage'
|
||||
import DesktopSidebar from './components/DesktopSidebar'
|
||||
import { getMainNavItems } from './config/appNav'
|
||||
|
|
@ -267,6 +275,10 @@ function AppShell() {
|
|||
<Route path="reference-value-types" element={<AdminReferenceValueTypesPage/>}/>
|
||||
<Route path="csv-templates" element={<AdminCsvTemplatesPage />} />
|
||||
<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 path="/workflow-editor/:id" element={<WorkflowEditorPage/>}/>
|
||||
|
|
|
|||
144
frontend/src/components/NutritionFoodMap.jsx
Normal file
144
frontend/src/components/NutritionFoodMap.jsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../utils/api'
|
||||
|
||||
export default function NutritionFoodMap({ onChanged }) {
|
||||
const [unmapped, setUnmapped] = useState([])
|
||||
const [learned, setLearned] = useState([])
|
||||
const [error, setError] = useState(null)
|
||||
const [query, setQuery] = useState({})
|
||||
const [hits, setHits] = useState({})
|
||||
const [saving, setSaving] = useState(null)
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const [u, m] = await Promise.all([api.listUnmappedFoods(), api.listMyFoodMappings()])
|
||||
setUnmapped(u)
|
||||
setLearned(m)
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
const search = async (key, q) => {
|
||||
setQuery((s) => ({ ...s, [key]: q }))
|
||||
if (!q || q.length < 2) {
|
||||
setHits((s) => ({ ...s, [key]: [] }))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const rows = await api.searchBlsFoods(q)
|
||||
setHits((s) => ({ ...s, [key]: rows }))
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const assign = async (sourceName, foodId, key) => {
|
||||
setSaving(key)
|
||||
setError(null)
|
||||
try {
|
||||
await api.upsertMyFoodMapping({ source_name: sourceName, food_id: foodId })
|
||||
setHits((s) => ({ ...s, [key]: [] }))
|
||||
await load()
|
||||
onChanged?.()
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async (id) => {
|
||||
if (!confirm('Zuordnung wirklich löschen?')) return
|
||||
try {
|
||||
await api.deleteMyFoodMapping(id)
|
||||
await load()
|
||||
onChanged?.()
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">Lebensmittel zuordnen</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginBottom: 12 }}>
|
||||
Einmal bestätigt, bleibt die Zuordnung erhalten und gilt für spätere Importe.
|
||||
Du kannst sie jederzeit ändern oder löschen. Keine automatische KI-Zuordnung.
|
||||
</p>
|
||||
{error && <div style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 10 }}>{error}</div>}
|
||||
|
||||
<h3 style={{ fontSize: 14, margin: '12px 0 8px' }}>Offen ({unmapped.length})</h3>
|
||||
{unmapped.length === 0 && <p className="muted">Keine ungemappten Bezeichner.</p>}
|
||||
{unmapped.map((u) => {
|
||||
const key = u.source_name_normalized
|
||||
return (
|
||||
<div key={key} style={{ borderTop: '1px solid var(--border)', padding: '10px 0' }}>
|
||||
<div style={{ fontWeight: 600 }}>{u.source_name_raw}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
|
||||
{u.count}× · {u.first_date} – {u.last_date}
|
||||
</div>
|
||||
<input
|
||||
className="form-input"
|
||||
style={{ marginTop: 6 }}
|
||||
placeholder="BLS-Code oder Name suchen…"
|
||||
value={query[key] || ''}
|
||||
onChange={(e) => search(key, e.target.value)}
|
||||
/>
|
||||
{(hits[key] || []).map((h) => (
|
||||
<button
|
||||
key={h.id}
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ marginTop: 6, marginRight: 6 }}
|
||||
disabled={saving === key}
|
||||
onClick={() => assign(u.source_name_raw, h.id, key)}
|
||||
>
|
||||
{h.bls_code ? `${h.bls_code} · ` : ''}{h.name_de}
|
||||
{h.catalog_kind !== 'official_bls' ? ' (manuell)' : ''}
|
||||
</button>
|
||||
))}
|
||||
</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.bls_code ? `${m.bls_code} · ` : ''}{m.food_name_de}
|
||||
{m.catalog_kind !== 'official_bls' ? ' (manuell)' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => remove(m.id)}>Löschen</button>
|
||||
</div>
|
||||
))}
|
||||
</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: 4 }}>
|
||||
<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',
|
||||
label: 'Basiseinstellungen',
|
||||
|
|
|
|||
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>
|
||||
)
|
||||
}
|
||||
56
frontend/src/pages/AdminBlsImportPage.jsx
Normal file
56
frontend/src/pages/AdminBlsImportPage.jsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../utils/api'
|
||||
|
||||
export default function AdminBlsImportPage() {
|
||||
const [status, setStatus] = useState(null)
|
||||
const [msg, setMsg] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const load = () => {
|
||||
api.adminBlsStatus().then(setStatus).catch((e) => setError(e.message))
|
||||
}
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
const run = async (kind, file, dry) => {
|
||||
setError(null)
|
||||
setMsg(dry ? 'Prüfe…' : 'Importiere…')
|
||||
try {
|
||||
const fn = kind === 'components' ? api.adminBlsImportComponents : api.adminBlsImportFoods
|
||||
const res = await fn(file, dry)
|
||||
setMsg(JSON.stringify(res, null, 2))
|
||||
if (!dry) load()
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
setMsg(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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.
|
||||
Zuerst Components, dann die Datendatei. Immer zuerst „Prüfen“.
|
||||
</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>}
|
||||
<div className="form-row" style={{ marginTop: 16 }}>
|
||||
<label className="form-label">Components XLSX</label>
|
||||
<input type="file" accept=".xlsx" onChange={(e) => e.target.files[0] && run('components', e.target.files[0], true)} />
|
||||
<input type="file" accept=".xlsx" onChange={(e) => e.target.files[0] && run('components', e.target.files[0], false)} />
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text3)' }}>Erstes Feld = prüfen, zweites = anwenden.</p>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Daten XLSX</label>
|
||||
<input type="file" accept=".xlsx" onChange={(e) => e.target.files[0] && run('foods', e.target.files[0], true)} />
|
||||
<input type="file" accept=".xlsx" onChange={(e) => e.target.files[0] && run('foods', e.target.files[0], false)} />
|
||||
</div>
|
||||
{msg && <pre style={{ whiteSpace: 'pre-wrap', fontSize: 12, background: 'var(--surface2)', padding: 12, borderRadius: 8 }}>{msg}</pre>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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,6 +1,7 @@
|
|||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Upload, CheckCircle, TrendingUp, Info } from 'lucide-react'
|
||||
import UsageBadge from '../components/UsageBadge'
|
||||
import NutritionFoodMap, { DayMarkButtons } from '../components/NutritionFoodMap'
|
||||
import {
|
||||
LineChart, Line, BarChart, Bar, XAxis, YAxis, Tooltip,
|
||||
ResponsiveContainer, CartesianGrid, Legend, ReferenceLine, ScatterChart, Scatter
|
||||
|
|
@ -324,9 +325,13 @@ function DataTab({ entries, onUpdate }) {
|
|||
</div>
|
||||
{e.source && (
|
||||
<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'}` : ''}
|
||||
</div>
|
||||
)}
|
||||
<div style={{marginTop:8}}>
|
||||
<DayMarkButtons date={e.date} markType={e.mark_type} onChanged={onUpdate} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -441,15 +446,18 @@ function ImportPanel({ onImported, usage = null }) {
|
|||
const [dragging,setDragging]= useState(false)
|
||||
const [tab, setTab] = useState('file') // 'file' | 'paste'
|
||||
const [pasteText, setPasteText] = useState('')
|
||||
const [overwrite, setOverwrite] = useState(false)
|
||||
const [conflicts, setConflicts] = useState([])
|
||||
const atLimit = usage && !usage.allowed
|
||||
|
||||
const runImport = async (file) => {
|
||||
if (atLimit) return
|
||||
setStatus('loading'); setError(null)
|
||||
setStatus('loading'); setError(null); setConflicts([])
|
||||
try {
|
||||
const result = await nutritionApi.importCsv(file)
|
||||
const result = await nutritionApi.importCsv(file, overwrite)
|
||||
if (result.days_imported === undefined) throw new Error(JSON.stringify(result))
|
||||
setStatus(result)
|
||||
setConflicts(result.conflicts || [])
|
||||
onImported()
|
||||
} catch(err) {
|
||||
setError('Import fehlgeschlagen: ' + err.message)
|
||||
|
|
@ -485,7 +493,12 @@ function ImportPanel({ onImported, usage = null }) {
|
|||
</div>
|
||||
<p style={{fontSize:13,color:'var(--text2)',marginBottom:10,lineHeight:1.6}}>
|
||||
In FDDB: <strong>Mein Tagebuch → Exportieren → CSV</strong> — dann hier importieren.
|
||||
Zeilen (Name, Menge, Uhrzeit) werden gespeichert, wenn die CSV sie enthält.
|
||||
</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 */}
|
||||
<div style={{display:'flex',gap:6,marginBottom:12}}>
|
||||
|
|
@ -567,7 +580,7 @@ function ImportPanel({ onImported, usage = null }) {
|
|||
<div style={{display:'flex',alignItems:'center',gap:6,marginBottom:4}}>
|
||||
<CheckCircle size={15}/><strong>Import erfolgreich</strong>
|
||||
</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 && (
|
||||
<div style={{fontSize:11,marginTop:2}}>
|
||||
{dayjs(status.date_range.from).format('DD.MM.YYYY')} – {dayjs(status.date_range.to).format('DD.MM.YYYY')}
|
||||
|
|
@ -575,6 +588,45 @@ function ImportPanel({ onImported, usage = null }) {
|
|||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
@ -837,6 +889,9 @@ export default function NutritionPage() {
|
|||
<button className={'tab'+(inputTab==='import'?' active':'')} onClick={()=>setInputTab('import')}>
|
||||
📥 Import
|
||||
</button>
|
||||
<button className={'tab'+(inputTab==='map'?' active':'')} onClick={()=>setInputTab('map')}>
|
||||
Zuordnen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Entry Form */}
|
||||
|
|
@ -853,6 +908,8 @@ export default function NutritionPage() {
|
|||
</>
|
||||
)}
|
||||
|
||||
{inputTab==='map' && <NutritionFoodMap onChanged={load} />}
|
||||
|
||||
{loading && <div className="empty-state"><div className="spinner"/></div>}
|
||||
|
||||
{!loading && !hasData && (
|
||||
|
|
|
|||
|
|
@ -458,6 +458,33 @@ export default function SettingsPage() {
|
|||
</Link>
|
||||
</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 */}
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">🔐 Konto</div>
|
||||
|
|
|
|||
|
|
@ -210,11 +210,43 @@ export const api = {
|
|||
},
|
||||
|
||||
// Nutrition
|
||||
importCsv: async(file)=>{
|
||||
importCsv: async(file, overwriteExisting=false)=>{
|
||||
const fd=new FormData();fd.append('file',file)
|
||||
const r=await fetch(`${BASE}/nutrition/import-csv`,{method:'POST',body:fd,headers:hdrs()})
|
||||
const qs = overwriteExisting ? '?overwrite_existing=true' : ''
|
||||
const r=await fetch(`${BASE}/nutrition/import-csv${qs}`,{method:'POST',body:fd,headers:hdrs()})
|
||||
const d=await r.json();if(!r.ok)throw new Error(formatFastApiDetail(d.detail, JSON.stringify(d)));return d
|
||||
},
|
||||
listNutritionItems: (date) => req(date ? `/nutrition/items?date=${date}` : '/nutrition/items'),
|
||||
listUnmappedFoods: () => req('/nutrition/unmapped'),
|
||||
listNutritionMarks: () => req('/nutrition/marks'),
|
||||
putNutritionDayMark: (date, d) => req(`/nutrition/days/${date}/mark`, jput(d)),
|
||||
deleteNutritionDayMark: (date) => req(`/nutrition/days/${date}/mark`, {method:'DELETE'}),
|
||||
resolveNutritionConflicts: (decisions) => req('/nutrition/import-conflicts/resolve', json({decisions})),
|
||||
searchBlsFoods: (q, limit=20) => req(`/bls/foods?q=${encodeURIComponent(q||'')}&limit=${limit}`),
|
||||
createUserFood: (d) => req('/bls/foods/manual', json(d)),
|
||||
listMyFoodMappings: () => req('/bls/mappings'),
|
||||
upsertMyFoodMapping: (d) => req('/bls/mappings', json(d)),
|
||||
deleteMyFoodMapping: (id) => req(`/bls/mappings/${id}`, {method:'DELETE'}),
|
||||
adminBlsStatus: () => req('/admin/bls/status'),
|
||||
adminBlsImportComponents: async (file, dryRun=true) => {
|
||||
const fd=new FormData();fd.append('file',file)
|
||||
const r=await fetch(`${BASE}/admin/bls/import/components?dry_run=${dryRun}`,{method:'POST',body:fd,headers:hdrs()})
|
||||
const d=await r.json();if(!r.ok)throw new Error(formatFastApiDetail(d.detail, JSON.stringify(d)));return d
|
||||
},
|
||||
adminBlsImportFoods: async (file, dryRun=true) => {
|
||||
const fd=new FormData();fd.append('file',file)
|
||||
const r=await fetch(`${BASE}/admin/bls/import/foods?dry_run=${dryRun}`,{method:'POST',body:fd,headers:hdrs()})
|
||||
const d=await r.json();if(!r.ok)throw new Error(formatFastApiDetail(d.detail, JSON.stringify(d)));return d
|
||||
},
|
||||
adminBlsFoods: (q, kind) => req(`/admin/bls/foods?${q?('q='+encodeURIComponent(q)+'&'):''}${kind?('kind='+kind):''}`),
|
||||
adminBlsFoodDetail: (id) => req(`/admin/bls/foods/${id}`),
|
||||
adminCreateManualFood: (d) => req('/admin/bls/foods/manual', json(d)),
|
||||
adminBlsAttributes: () => req('/admin/bls/attributes'),
|
||||
adminCreateBlsAttribute: (d) => req('/admin/bls/attributes', json(d)),
|
||||
adminListFoodMappings: (globalOnly) => req(`/admin/food-mappings${globalOnly?'?global_only=true':''}`),
|
||||
adminCreateFoodMapping: (d) => req('/admin/food-mappings', json(d)),
|
||||
adminDeleteFoodMapping: (id) => req(`/admin/food-mappings/${id}`, {method:'DELETE'}),
|
||||
adminFoodMappingCoverage: () => req('/admin/food-mappings/stats/coverage'),
|
||||
listNutrition: (l=365) => req(`/nutrition?limit=${l}`),
|
||||
nutritionCorrelations: () => req('/nutrition/correlations'),
|
||||
nutritionWeekly: (w=16) => req(`/nutrition/weekly?weeks=${w}`),
|
||||
|
|
|
|||
|
|
@ -55,3 +55,35 @@ test('5. Keine kritischen Console-Fehler', async ({ page }) => {
|
|||
console.log('Keine kritischen Console-Fehler');
|
||||
}
|
||||
});
|
||||
|
||||
test('FEATURE: Ernährung — Einzelerfassung, Import-Policy-Hinweis, Zuordnen', async ({ page }) => {
|
||||
await page.goto('/nutrition');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.getByRole('button', { name: /Einzelerfassung/i })).toBeVisible();
|
||||
await page.getByRole('button', { name: /Import/i }).click();
|
||||
await expect(page.locator('.card').filter({ hasText: 'FDDB CSV Import' })).toBeVisible();
|
||||
await expect(page.getByText(/Vorhandene Tagesmakros überschreiben/)).toBeVisible();
|
||||
await page.getByRole('button', { name: /Zuordnen/i }).click();
|
||||
await expect(page.getByText(/Lebensmittel zuordnen/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('FEATURE: Settings — Ernährungs-Import-Policy', async ({ page }) => {
|
||||
await page.goto('/settings');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.getByText('Ernährungs-Import')).toBeVisible();
|
||||
await expect(page.locator('select').filter({ has: page.locator('option[value="prompt"]') })).toBeVisible();
|
||||
});
|
||||
|
||||
test('API: nutrition unmapped + marks erreichbar', async ({ page }) => {
|
||||
await page.goto('/nutrition');
|
||||
await page.waitForLoadState('networkidle');
|
||||
const token = await page.evaluate(() => localStorage.getItem('bodytrack_token'));
|
||||
expect(token).toBeTruthy();
|
||||
const headers = { 'X-Auth-Token': token };
|
||||
const unmapped = await page.request.get('/api/nutrition/unmapped', { headers });
|
||||
expect(unmapped.ok()).toBeTruthy();
|
||||
const marks = await page.request.get('/api/nutrition/marks', { headers });
|
||||
expect(marks.ok()).toBeTruthy();
|
||||
const foods = await page.request.get('/api/bls/foods?q=hafer', { headers });
|
||||
expect(foods.ok()).toBeTruthy();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ test.describe('Issue-Audit UI', () => {
|
|||
await page.waitForLoadState('networkidle');
|
||||
const importCard = page.locator('.card').filter({ hasText: 'FDDB CSV Import' });
|
||||
await expect(importCard).toBeVisible();
|
||||
await expect(importCard.getByText(/Vorhandene Tagesmakros überschreiben/)).toBeVisible();
|
||||
const titleRow = importCard.locator('.card-title.badge-container-right');
|
||||
if (await titleRow.count()) {
|
||||
await expect(titleRow).toBeVisible();
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user