fix: FDDB-Zeilen im CSV-Import speichern und in Ernährung zeigen
All checks were successful
Deploy Development / deploy (push) Successful in 1m12s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 23s

Bezeichnung/Menge auch ohne Vorlagen-Mapping, Zuordnen-Hinweis, Tagesliste mit Lebensmitteln.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-09-12 15:11:37 +02:00
parent a07a668de2
commit 4b1be3019d
7 changed files with 141 additions and 5 deletions

View File

@ -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"),
}

View File

@ -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"}),

View File

@ -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"],
}

View File

@ -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}")

View File

@ -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})

View File

@ -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 (
<div style={{ marginTop: 8 }}>
<button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} onClick={toggle}>
{open ? 'Lebensmittel ausblenden' : 'Lebensmittel zeigen'}
</button>
{error && <p style={{ color: 'var(--danger)', fontSize: 12, margin: '6px 0 0' }}>{error}</p>}
{open && Array.isArray(items) && (
<ul style={{ margin: '8px 0 0', paddingLeft: 18, fontSize: 13, color: 'var(--text1)' }}>
{items.length === 0 && <li style={{ color: 'var(--text3)' }}>Keine Zeilen für diesen Tag</li>}
{items.map((it) => (
<li key={it.id}>
{it.source_name_raw}
{it.quantity_raw ? ` · ${it.quantity_raw}` : ''}
{it.food_name_de ? `${it.food_name_de}` : ' · noch nicht zugeordnet'}
</li>
))}
</ul>
)}
</div>
)
}
function DataTab({ entries, onUpdate }) {
const [editId, setEditId] = useState(null)
const [editValues, setEditValues] = useState({})
@ -327,8 +370,14 @@ function DataTab({ entries, onUpdate }) {
<div style={{fontSize:10, color:'var(--text3)', marginTop:4}}>
Quelle: {e.source}{e.macro_origin ? ` · Makros: ${e.macro_origin}` : ''}
{e.mark_type ? ` · ${e.mark_type === 'fasting' ? 'Fastentag' : 'unvollständig'}` : ''}
{e.item_count ? ` · ${e.item_count} Lebensmittel` : ''}
</div>
)}
{e.item_count > 0 ? (
<DayItems date={e.date} />
) : (
<div style={{fontSize:12, color:'var(--text3)', marginTop:6}}>Keine einzelnen Lebensmittelzeilen</div>
)}
<div style={{marginTop:8}}>
<DayMarkButtons date={e.date} markType={e.mark_type} onChanged={onUpdate} />
</div>
@ -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 (
<div className="capture-page">
<h1 className="page-title">Ernährung</h1>
{unmappedCount > 0 && (
<div className="card" style={{ marginBottom: 12, padding: 12, fontSize: 13 }}>
{unmappedCount} Lebensmittel noch ohne BLS-Zuordnung.{' '}
<button type="button" className="btn btn-secondary" style={{ marginLeft: 8 }} onClick={() => setInputTab('map')}>
Jetzt zuordnen
</button>
</div>
)}
{/* Input Method Tabs */}
<div className="tabs section-gap" style={{marginBottom:0}}>
@ -890,7 +950,7 @@ export default function NutritionPage() {
📥 Import
</button>
<button className={'tab'+(inputTab==='map'?' active':'')} onClick={()=>setInputTab('map')}>
Zuordnen
Zuordnen{unmappedCount ? ` (${unmappedCount})` : ''}
</button>
</div>

View File

@ -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')