"""Nutrition diary items, three macro sums, import policy, attribute resolve.""" from __future__ import annotations import uuid from datetime import date, datetime from typing import Any from data_layer.food_mapping import ( get_food_mapping_with_cursor, normalize_food_name, parse_quantity_g, ) MACRO_ATTR_KEYS = { "kcal": "ENERCC", "protein_g": "PROT625", "fat_g": "FAT", "carbs_g": "CHO", } POLICIES = frozenset({"prompt", "overwrite_catalog", "overwrite_fddb", "keep_existing"}) def _f(v: Any) -> float: if v is None or v == "": return 0.0 try: return float(v) except (TypeError, ValueError): return 0.0 def _round_macros(d: dict[str, float]) -> dict[str, float]: return { "kcal": round(_f(d.get("kcal")), 1), "protein_g": round(_f(d.get("protein_g")), 1), "fat_g": round(_f(d.get("fat_g")), 1), "carbs_g": round(_f(d.get("carbs_g")), 1), } def macros_differ(a: dict[str, float], b: dict[str, float]) -> bool: aa, bb = _round_macros(a), _round_macros(b) return any(aa[k] != bb[k] for k in aa) def get_import_policy(cur, profile_id: str) -> str: cur.execute( "SELECT nutrition_import_conflict_policy FROM profiles WHERE id = %s", (profile_id,), ) row = cur.fetchone() if not row: return "prompt" pol = row.get("nutrition_import_conflict_policy") or "prompt" return pol if pol in POLICIES else "prompt" def catalog_macros_for_item(cur, food_id: str | None, quantity_g: float | None) -> dict[str, float] | None: if not food_id or quantity_g is None or quantity_g <= 0: return None cur.execute( """ SELECT a.attr_key, v.value_num, v.is_trace FROM food_attribute_values v JOIN food_attributes a ON a.id = v.attribute_id WHERE v.food_id = %s AND a.attr_key = ANY(%s) AND a.data_type = 'num_per_100g' """, (food_id, list(MACRO_ATTR_KEYS.values())), ) by_key = {r["attr_key"]: r for r in cur.fetchall()} if not by_key: return None out = {} factor = float(quantity_g) / 100.0 missing = False for field, key in MACRO_ATTR_KEYS.items(): row = by_key.get(key) if not row or row.get("is_trace") or row.get("value_num") is None: missing = True break out[field] = float(row["value_num"]) * factor return None if missing else out def compute_day_macro_sums(cur, profile_id: str, day: date | str) -> dict[str, Any]: cur.execute( """ SELECT kcal, protein_g, fat_g, carbs_g, macro_origin, has_items FROM nutrition_log WHERE profile_id = %s AND date = %s """, (profile_id, day), ) existing = cur.fetchone() existing_macros = ( _round_macros(existing) if existing else None ) cur.execute( """ SELECT food_id, recipe_id, quantity_g, fddb_kcal, fddb_protein_g, fddb_fat_g, fddb_carbs_g, value_origin FROM nutrition_items WHERE profile_id = %s AND date = %s """, (profile_id, day), ) items = cur.fetchall() fddb = {"kcal": 0.0, "protein_g": 0.0, "fat_g": 0.0, "carbs_g": 0.0} catalog = {"kcal": 0.0, "protein_g": 0.0, "fat_g": 0.0, "carbs_g": 0.0} mapped = unmapped = 0 used_bls = used_fddb = False for it in items: fddb["kcal"] += _f(it.get("fddb_kcal")) fddb["protein_g"] += _f(it.get("fddb_protein_g")) fddb["fat_g"] += _f(it.get("fddb_fat_g")) fddb["carbs_g"] += _f(it.get("fddb_carbs_g")) if it.get("recipe_id") and not it.get("food_id"): from data_layer.food_recipes import catalog_macros_for_recipe cat = catalog_macros_for_recipe(cur, profile_id, it["recipe_id"], it.get("quantity_g")) else: cat = catalog_macros_for_item(cur, it.get("food_id"), it.get("quantity_g")) if cat: mapped += 1 used_bls = True for k in catalog: catalog[k] += cat[k] else: unmapped += 1 used_fddb = True catalog["kcal"] += _f(it.get("fddb_kcal")) catalog["protein_g"] += _f(it.get("fddb_protein_g")) catalog["fat_g"] += _f(it.get("fddb_fat_g")) catalog["carbs_g"] += _f(it.get("fddb_carbs_g")) origin = "mixed" if used_bls and not used_fddb: origin = "bls" elif used_fddb and not used_bls: origin = "fddb" if not items: origin = "manual" return { "existing": existing_macros, "fddb": _round_macros(fddb) if items else None, "catalog": _round_macros(catalog) if items else None, "mapped_item_count": mapped, "unmapped_item_count": unmapped, "has_items": bool(items), "catalog_origin": origin, "has_log": existing is not None, "macro_origin": existing["macro_origin"] if existing else None, } def apply_nutrition_day_macros( cur, profile_id: str, day: date | str, macros: dict[str, float], *, macro_origin: str, source: str = "csv", confirm: bool = False, ) -> str: m = _round_macros(macros) cur.execute("SELECT id FROM nutrition_log WHERE profile_id = %s AND date = %s", (profile_id, day)) row = cur.fetchone() counts = compute_day_macro_sums(cur, profile_id, day) extra = ( counts["mapped_item_count"], counts["unmapped_item_count"], counts["has_items"], ) confirmed = datetime.utcnow() if confirm else None if row: cur.execute( """ UPDATE nutrition_log SET kcal=%s, protein_g=%s, fat_g=%s, carbs_g=%s, source=%s, macro_origin=%s, mapped_item_count=%s, unmapped_item_count=%s, has_items=%s, last_import_at=NOW(), macros_confirmed_at=COALESCE(%s, macros_confirmed_at) WHERE profile_id=%s AND date=%s """, ( m["kcal"], m["protein_g"], m["fat_g"], m["carbs_g"], source, macro_origin, extra[0], extra[1], extra[2], confirmed, profile_id, day, ), ) return "updated" eid = str(uuid.uuid4()) cur.execute( """ INSERT INTO nutrition_log ( id, profile_id, date, kcal, protein_g, fat_g, carbs_g, source, macro_origin, mapped_item_count, unmapped_item_count, has_items, last_import_at, macros_confirmed_at, created ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,NOW(),%s,CURRENT_TIMESTAMP) """, ( eid, profile_id, day, m["kcal"], m["protein_g"], m["fat_g"], m["carbs_g"], source, macro_origin, extra[0], extra[1], extra[2], confirmed, ), ) return "created" def _accumulate_food_qty(cur, acc: dict[int, list[float]], food_id: str, quantity_g: float) -> None: if not food_id or not quantity_g or quantity_g <= 0: return cur.execute( """ SELECT v.attribute_id, v.value_num, v.is_trace, a.data_type FROM food_attribute_values v JOIN food_attributes a ON a.id = v.attribute_id WHERE v.food_id = %s AND a.data_type = 'num_per_100g' AND v.value_num IS NOT NULL AND v.is_trace = false """, (food_id,), ) factor = float(quantity_g) / 100.0 for row in cur.fetchall(): acc.setdefault(row["attribute_id"], []).append(float(row["value_num"]) * factor) def rebuild_daily_nutrients(cur, profile_id: str, day: date | str) -> None: cur.execute( "DELETE FROM nutrition_daily_nutrients WHERE profile_id = %s AND date = %s", (profile_id, day), ) cur.execute( """ SELECT food_id, recipe_id, quantity_g FROM nutrition_items WHERE profile_id = %s AND date = %s """, (profile_id, day), ) acc: dict[int, list[float]] = {} from data_layer.food_recipes import mapped_ingredient_quantities for it in cur.fetchall(): if it.get("food_id"): _accumulate_food_qty(cur, acc, it["food_id"], _f(it.get("quantity_g"))) continue if it.get("recipe_id"): parts = mapped_ingredient_quantities(cur, profile_id, it["recipe_id"], it.get("quantity_g")) if not parts: continue for part in parts: _accumulate_food_qty(cur, acc, part["food_id"], part["quantity_g"]) for attr_id, vals in acc.items(): cur.execute( """ INSERT INTO nutrition_daily_nutrients (profile_id, date, attribute_id, value, contributing_item_count, updated_at) VALUES (%s, %s, %s, %s, %s, NOW()) """, (profile_id, day, attr_id, round(sum(vals), 6), len(vals)), ) def _item_value_origin(mapping: dict | None) -> str: if not mapping: return "fddb" kind = mapping.get("catalog_kind") if kind == "official_bls": return "bls" if kind in ("manual_admin", "manual_user"): return "manual_catalog" return "fddb" def replace_csv_items_for_dates( cur, profile_id: str, rows: list[dict[str, Any]], *, policy: str, policy_override: str | None = None, ) -> dict[str, Any]: """ Replace csv-sourced items for the dates present in rows. Returns conflicts when policy is prompt and existing macros differ. """ effective = policy_override if policy_override in POLICIES else policy by_date: dict[str, list[dict]] = {} for row in rows: d = row.get("date") if hasattr(d, "isoformat"): iso = d.isoformat() else: iso = str(d)[:10] if not iso: continue by_date.setdefault(iso, []).append(row) conflicts = [] days_written = 0 items_written = 0 new_log_days = 0 for iso, day_rows in by_date.items(): cur.execute( """ DELETE FROM nutrition_items WHERE profile_id = %s AND date = %s AND source = 'csv' """, (profile_id, iso), ) for raw in day_rows: name = (raw.get("food_name") or raw.get("source_name_raw") or "").strip() if not name: continue qty_raw = raw.get("quantity_raw") qty_g = parse_quantity_g(qty_raw if qty_raw is not None else name) mapping = get_food_mapping_with_cursor(cur, name, profile_id) logged_at = raw.get("logged_at") cur.execute( """ INSERT INTO nutrition_items ( id, profile_id, date, logged_at, source_name_raw, source_name_normalized, source_system, quantity_raw, quantity_g, fddb_kcal, fddb_protein_g, fddb_fat_g, fddb_carbs_g, food_id, mapping_id, value_origin, source, recipe_id ) VALUES ( %s,%s,%s,%s,%s,%s,'fddb',%s,%s,%s,%s,%s,%s,%s,%s,%s,'csv',%s ) """, ( str(uuid.uuid4()), profile_id, iso, logged_at, name, normalize_food_name(name), str(qty_raw) if qty_raw is not None else None, qty_g, _f(raw.get("fddb_kcal") if raw.get("fddb_kcal") is not None else raw.get("kcal")), _f(raw.get("fddb_protein_g") if raw.get("fddb_protein_g") is not None else raw.get("protein_g")), _f(raw.get("fddb_fat_g") if raw.get("fddb_fat_g") is not None else raw.get("fat_g")), _f(raw.get("fddb_carbs_g") if raw.get("fddb_carbs_g") is not None else raw.get("carbs_g")), mapping["food_id"] if mapping else None, mapping["mapping_id"] if mapping else None, _item_value_origin(mapping), None, ), ) items_written += 1 from data_layer.food_recipes import link_recipes_to_items link_recipes_to_items(cur, profile_id) days_written += 1 sums = compute_day_macro_sums(cur, profile_id, iso) rebuild_daily_nutrients(cur, profile_id, iso) if not sums["has_items"]: continue if not sums["has_log"]: apply_nutrition_day_macros( cur, profile_id, iso, sums["catalog"], macro_origin=sums["catalog_origin"], source="csv", ) new_log_days += 1 continue existing = sums["existing"] catalog = sums["catalog"] fddb = sums["fddb"] differ = macros_differ(existing, catalog) or macros_differ(existing, fddb) if effective == "keep_existing": _touch_item_counts(cur, profile_id, iso, sums) continue if effective == "overwrite_catalog": apply_nutrition_day_macros( cur, profile_id, iso, catalog, macro_origin=sums["catalog_origin"], source="csv", ) continue if effective == "overwrite_fddb": apply_nutrition_day_macros( cur, profile_id, iso, fddb, macro_origin="fddb", source="csv", ) continue # prompt if differ: conflicts.append({ "date": iso, "existing": existing, "fddb": fddb, "catalog": catalog, "catalog_origin": sums["catalog_origin"], "mapped_item_count": sums["mapped_item_count"], "unmapped_item_count": sums["unmapped_item_count"], }) else: apply_nutrition_day_macros( cur, profile_id, iso, catalog, macro_origin=sums["catalog_origin"], source="csv", ) return { "days_written": days_written, "items_written": items_written, "new_log_days": new_log_days, "conflicts": conflicts, "policy": effective, } def _touch_item_counts(cur, profile_id: str, day: str, sums: dict) -> None: cur.execute( """ UPDATE nutrition_log SET mapped_item_count=%s, unmapped_item_count=%s, has_items=%s, last_import_at=NOW() WHERE profile_id=%s AND date=%s """, ( sums["mapped_item_count"], sums["unmapped_item_count"], sums["has_items"], profile_id, day, ), ) def resolve_choice_macros(sums: dict[str, Any], choice: str) -> tuple[dict[str, float], str]: if choice == "existing": return sums["existing"], "user_confirmed" if choice == "fddb": return sums["fddb"], "fddb" if choice == "catalog": return sums["catalog"], sums.get("catalog_origin") or "mixed" raise ValueError("Ungültige Wahl (existing|fddb|catalog)") def resolve_food_attributes(cur, food_id: str) -> list[dict]: cur.execute( """ SELECT a.attr_key, a.name_de, a.unit, a.category, a.data_type, a.origin AS attr_origin, v.value_num, v.value_bool, v.value_text, v.is_trace, v.origin_code FROM food_attributes a LEFT JOIN food_attribute_values v ON v.attribute_id = a.id AND v.food_id = %s WHERE a.is_active = true ORDER BY a.sort_order, a.attr_key """, (food_id,), ) return [dict(r) for r in cur.fetchall()] def dates_for_normalized_name(cur, profile_id: str, source_name_normalized: str) -> list[str]: cur.execute( """ SELECT DISTINCT date::text AS date FROM nutrition_items WHERE profile_id = %s AND source_name_normalized = %s UNION SELECT DISTINCT i.date::text AS date FROM nutrition_items i JOIN food_recipe_ingredients ri ON ri.recipe_id = i.recipe_id WHERE i.profile_id = %s AND ri.source_name_normalized = %s """, (profile_id, source_name_normalized, profile_id, source_name_normalized), ) return [r["date"] for r in cur.fetchall()]