fix: FDDB-Zeilen im CSV-Import speichern und in Ernährung zeigen
Bezeichnung/Menge auch ohne Vorlagen-Mapping, Zuordnen-Hinweis, Tagesliste mit Lebensmitteln. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
a07a668de2
commit
4b1be3019d
|
|
@ -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"),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"}),
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user