diff --git a/backend/csv_parser/executor.py b/backend/csv_parser/executor.py index a1106c5..056c128 100644 --- a/backend/csv_parser/executor.py +++ b/backend/csv_parser/executor.py @@ -11,7 +11,31 @@ from typing import Any import logging -from csv_parser.core import iter_csv_dict_rows, resolve_effective_csv_delimiter +from csv_parser.core import iter_csv_dict_rows, normalize_header_for_signature, resolve_effective_csv_delimiter + +_FOOD_NAME_HEADERS = frozenset({ + "bezeichnung", "lebensmittel", "food", "food_name", "name", "gericht", +}) +_QTY_HEADERS = frozenset({ + "menge", "quantity", "quantity_raw", "portion", "amount", "gramm", +}) + + +def guess_nutrition_item_fields(csv_row: dict[str, Any] | None) -> tuple[str | None, str | None]: + """FDDB-Spalten auch ohne Vorlagen-Mapping auf food_name / menge erkennen.""" + name = qty = None + for key, val in (csv_row or {}).items(): + if val is None: + continue + text = str(val).strip().strip('"') + if not text: + continue + norm = normalize_header_for_signature(str(key)) + if name is None and (norm in _FOOD_NAME_HEADERS or "bezeichnung" in norm): + name = text + if qty is None and (norm in _QTY_HEADERS or norm.startswith("menge")): + qty = text + return name, qty from csv_parser.import_row_processing import ( aggregate_mapped_rows, resolve_import_row_processing, @@ -181,6 +205,8 @@ def run_universal_csv_import( "error_details": error_details[:50], "new_entries": stats.get("new_entries", stats.get("inserted", 0)), "affected_ids": dict(affected_ids), + "items_written": stats.get("items_written", 0), + "unmapped_names": stats.get("unmapped_names", 0), } return out @@ -203,6 +229,11 @@ def _import_nutrition( for csv_row in iter_csv_dict_rows(text, delim, has_header=has_header): rows_total += 1 mapped = build_row_after_mapping(csv_row, fm, tc, module="nutrition") + guessed_name, guessed_qty = guess_nutrition_item_fields(csv_row) + if not mapped.get("food_name") and guessed_name: + mapped["food_name"] = guessed_name + if not mapped.get("quantity_raw") and guessed_qty: + mapped["quantity_raw"] = guessed_qty d = coerce_date(mapped.get("date")) if d is None: error_details.append({"row": rows_total, "error": "Datum fehlt oder ungültig"}) @@ -246,6 +277,15 @@ def _import_nutrition( policy = get_import_policy(cur, profile_id) ingest = replace_csv_items_for_dates(cur, profile_id, item_rows, policy=policy) + cur.execute( + """ + SELECT COUNT(DISTINCT source_name_normalized) AS n + FROM nutrition_items + WHERE profile_id = %s AND food_id IS NULL + """, + (profile_id,), + ) + unmapped_row = cur.fetchone() or {} return { "rows_total": rows_total, "inserted": ingest.get("new_log_days", 0), @@ -253,6 +293,7 @@ def _import_nutrition( "skipped": skipped_groups, "new_entries": ingest.get("new_log_days", 0), "items_written": ingest.get("items_written", 0), + "unmapped_names": int(unmapped_row.get("n") or 0), "conflicts": ingest.get("conflicts") or [], "policy": ingest.get("policy"), } diff --git a/backend/csv_parser/mapping_suggest.py b/backend/csv_parser/mapping_suggest.py index 905bf44..9fcedfe 100644 --- a/backend/csv_parser/mapping_suggest.py +++ b/backend/csv_parser/mapping_suggest.py @@ -20,6 +20,8 @@ _MODULE_HEADER_ALIASES: dict[str, dict[str, frozenset[str]]] = { "protein_g": frozenset({"protein", "eiwei", "eiweiss"}), "fat_g": frozenset({"fett", "fat", "lipid"}), "carbs_g": frozenset({"kh", "carb", "kohlenhydr", "carbs", "sugar", "zucker"}), + "food_name": frozenset({"bezeichnung", "lebensmittel", "food", "gericht"}), + "quantity_raw": frozenset({"menge", "quantity", "portion", "gramm"}), }, "weight": { "date": frozenset({"datum", "date", "tag", "day", "zeit"}), diff --git a/backend/routers/csv_import.py b/backend/routers/csv_import.py index b5ccfb3..5c45625 100644 --- a/backend/routers/csv_import.py +++ b/backend/routers/csv_import.py @@ -712,6 +712,8 @@ async def csv_import_execute( "updated": result["rows_updated"], "skipped": result["rows_skipped"], "errors": result["rows_errors"], + "items_written": result.get("items_written", 0), + "unmapped_names": result.get("unmapped_names", 0), }, "error_details": result["error_details"], } diff --git a/backend/routers/nutrition.py b/backend/routers/nutrition.py index 4ebde2d..d3bdede 100644 --- a/backend/routers/nutrition.py +++ b/backend/routers/nutrition.py @@ -227,7 +227,23 @@ def list_nutrition(limit: int=365, x_profile_id: Optional[str]=Header(default=No """, (pid, limit), ) - return [r2d(r) for r in cur.fetchall()] + rows = [r2d(r) for r in cur.fetchall()] + dates = [r["date"] for r in rows if r.get("date") is not None] + counts = {} + if dates: + cur.execute( + """ + SELECT date, COUNT(*) AS item_count + FROM nutrition_items + WHERE profile_id = %s AND date = ANY(%s) + GROUP BY date + """, + (pid, dates), + ) + counts = {str(r["date"]): int(r["item_count"] or 0) for r in cur.fetchall()} + for r in rows: + r["item_count"] = counts.get(str(r.get("date")), 0) + return rows @router.get("/by-date/{date}") diff --git a/backend/tests/test_food_mapping.py b/backend/tests/test_food_mapping.py index 39c11ed..df7d7bd 100644 --- a/backend/tests/test_food_mapping.py +++ b/backend/tests/test_food_mapping.py @@ -1,3 +1,4 @@ +from csv_parser.executor import guess_nutrition_item_fields from data_layer.food_mapping import normalize_food_name, parse_quantity_g from data_layer.nutrition_items import macros_differ @@ -13,6 +14,16 @@ def test_parse_quantity_g(): assert parse_quantity_g("1 Stück") is None +def test_guess_fddb_bezeichnung_without_template_mapping(): + name, qty = guess_nutrition_item_fields({ + "bezeichnung": "50 g Hähnchen", + "menge": "50 g", + "kj": "800", + }) + assert name == "50 g Hähnchen" + assert qty == "50 g" + + def test_macros_differ_rounds(): assert not macros_differ({"kcal": 1.04, "protein_g": 0, "fat_g": 0, "carbs_g": 0}, {"kcal": 1.0, "protein_g": 0, "fat_g": 0, "carbs_g": 0}) assert macros_differ({"kcal": 10, "protein_g": 0, "fat_g": 0, "carbs_g": 0}, {"kcal": 11, "protein_g": 0, "fat_g": 0, "carbs_g": 0}) diff --git a/frontend/src/pages/NutritionPage.jsx b/frontend/src/pages/NutritionPage.jsx index 4f9a35b..15a0d8f 100644 --- a/frontend/src/pages/NutritionPage.jsx +++ b/frontend/src/pages/NutritionPage.jsx @@ -195,6 +195,49 @@ function EntryForm({ onSaved }) { } // ── Data Tab (Editable Entry List) ─────────────────────────────────────────── +function DayItems({ date }) { + const [items, setItems] = useState(null) + const [open, setOpen] = useState(false) + const [error, setError] = useState(null) + + const toggle = async () => { + if (open) { + setOpen(false) + return + } + if (!items) { + try { + setItems(await nutritionApi.listNutritionItems(date)) + } catch (e) { + setError(e.message) + return + } + } + setOpen(true) + } + + return ( +
+ + {error &&

{error}

} + {open && Array.isArray(items) && ( + + )} +
+ ) +} + function DataTab({ entries, onUpdate }) { const [editId, setEditId] = useState(null) const [editValues, setEditValues] = useState({}) @@ -327,8 +370,14 @@ function DataTab({ entries, onUpdate }) {
Quelle: {e.source}{e.macro_origin ? ` · Makros: ${e.macro_origin}` : ''} {e.mark_type ? ` · ${e.mark_type === 'fasting' ? 'Fastentag' : 'unvollständig'}` : ''} + {e.item_count ? ` · ${e.item_count} Lebensmittel` : ''}
)} + {e.item_count > 0 ? ( + + ) : ( +
Keine einzelnen Lebensmittelzeilen
+ )}
@@ -849,6 +898,7 @@ export default function NutritionPage() { const [hasData, setHasData]= useState(false) const [importHistoryKey, setImportHistoryKey] = useState(Date.now()) // BUG-004 fix const [nutritionUsage, setNutritionUsage] = useState(null) + const [unmappedCount, setUnmappedCount] = useState(0) const loadUsage = () => { nutritionApi.getFeatureUsage().then(features => { @@ -860,16 +910,18 @@ export default function NutritionPage() { const load = async () => { setLoad(true) try { - const [corr, wkly, ent, prof] = await Promise.all([ + const [corr, wkly, ent, prof, unmapped] = await Promise.all([ nutritionApi.nutritionCorrelations(), nutritionApi.nutritionWeekly(16), nutritionApi.listNutrition(365), // BUG-002 fix: load raw entries nutritionApi.getActiveProfile(), + nutritionApi.listUnmappedFoods().catch(() => []), ]) setCorr(Array.isArray(corr)?corr:[]) setWeekly(Array.isArray(wkly)?wkly:[]) setEntries(Array.isArray(ent)?ent:[]) // BUG-002 fix setProf(prof) + setUnmappedCount(Array.isArray(unmapped) ? unmapped.length : 0) setHasData(Array.isArray(corr) && corr.some(d=>d.kcal)) } catch(e) { console.error('load error:', e) } finally { setLoad(false) } @@ -880,6 +932,14 @@ export default function NutritionPage() { return (

Ernährung

+ {unmappedCount > 0 && ( +
+ {unmappedCount} Lebensmittel noch ohne BLS-Zuordnung.{' '} + +
+ )} {/* Input Method Tabs */}
@@ -890,7 +950,7 @@ export default function NutritionPage() { 📥 Import
diff --git a/frontend/src/pages/UniversalCsvImportPage.jsx b/frontend/src/pages/UniversalCsvImportPage.jsx index eeb8432..390bbd9 100644 --- a/frontend/src/pages/UniversalCsvImportPage.jsx +++ b/frontend/src/pages/UniversalCsvImportPage.jsx @@ -201,10 +201,14 @@ export default function UniversalCsvImportPage() { setLastImport(res) const st = res.stats || {} const modLabel = MODULE_LABEL[res.module] || res.module || '' + const extra = [] + if (st.items_written) extra.push(`${st.items_written} Lebensmittelzeilen`) + if (st.unmapped_names) extra.push(`${st.unmapped_names} noch ohne Zuordnung — Ernährung → Zuordnen`) setSuccess( (modLabel ? `${modLabel}: ` : '') + `Import fertig — ${st.imported ?? 0} neu, ${st.updated ?? 0} aktualisiert, ` + - `${st.skipped ?? 0} übersprungen, ${st.errors ?? 0} Zeilenfehler.`, + `${st.skipped ?? 0} übersprungen, ${st.errors ?? 0} Zeilenfehler.` + + (extra.length ? ` ${extra.join(' · ')}.` : ''), ) } catch (e) { setError(e.message || 'Import fehlgeschlagen')