mitai-jinkendo/backend/data_layer/nutrition_items.py
Lars c1873a47b1
All checks were successful
Deploy Development / deploy (push) Successful in 1m4s
Build Test / pytest-backend (push) Successful in 9s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 22s
fix: Zuordnungen zuverlässig speichern und Mengen-Varianten zusammenfassen
Mapping bleibt erhalten, auch wenn der Nährwert-Rebuild länger dauert. Offene Liste führt 1 ml/2 ml Olivenöl als ein Lebensmittel. Dazu JSON-Sicherung, eigener Katalogeintrag und Gramm-pro-Einheit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-12 15:49:14 +02:00

467 lines
16 KiB
Python

"""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("grams_per_unit") if mapping else None,
)
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()]