"""FDDB → food_catalog mapping: normalize, lookup (user then global), learn, apply.""" from __future__ import annotations import re import unicodedata from typing import Any LEADING_QTY_RE = re.compile( r"^\s*\d+(?:[.,]\d+)?\s*(?:g|kg|ml|l|stück|stk|st\.?|portion(?:en)?)\b[\s,.:\-–]*", re.IGNORECASE, ) MULTISPACE_RE = re.compile(r"\s+") DECIMAL_IN_NAME_RE = re.compile(r"(\d),(\d)") def normalize_food_name(raw: str | None) -> str: if not raw: return "" s = unicodedata.normalize("NFKC", str(raw)).strip().strip('"').strip("'") s = LEADING_QTY_RE.sub("", s) s = DECIMAL_IN_NAME_RE.sub(r"\1.\2", s) s = MULTISPACE_RE.sub(" ", s).strip().lower() return s def parse_quantity_g(raw: str | None) -> float | None: if raw is None or str(raw).strip() == "": return None text = str(raw).strip().replace(",", ".") m = re.match(r"^\s*(\d+(?:\.\d+)?)\s*(g|gramm)?\s*$", text, re.IGNORECASE) if m: return round(float(m.group(1)), 3) return None def get_food_mapping_with_cursor( cur, source_name: str, profile_id: str | None = None, source_system: str = "fddb", ) -> dict[str, Any] | None: norm = normalize_food_name(source_name) if not norm: return None if profile_id: cur.execute( """ SELECT m.id AS mapping_id, m.food_id, m.profile_id, m.source, f.bls_code, f.name_de, f.catalog_kind FROM food_name_mappings m JOIN food_catalog f ON f.id = m.food_id WHERE m.source_system = %s AND m.source_name_normalized = %s AND m.profile_id = %s LIMIT 1 """, (source_system, norm, profile_id), ) row = cur.fetchone() if row: return dict(row) cur.execute( """ SELECT m.id AS mapping_id, m.food_id, m.profile_id, m.source, f.bls_code, f.name_de, f.catalog_kind FROM food_name_mappings m JOIN food_catalog f ON f.id = m.food_id WHERE m.source_system = %s AND m.source_name_normalized = %s AND m.profile_id IS NULL LIMIT 1 """, (source_system, norm), ) row = cur.fetchone() return dict(row) if row else None def upsert_food_mapping( cur, *, source_name_raw: str, food_id: str, profile_id: str | None, source: str = "bulk", source_system: str = "fddb", ) -> int: norm = normalize_food_name(source_name_raw) if not norm: raise ValueError("Leerer Lebensmittelname") if profile_id: cur.execute( """ SELECT id FROM food_name_mappings WHERE source_system = %s AND source_name_normalized = %s AND profile_id = %s """, (source_system, norm, profile_id), ) else: cur.execute( """ SELECT id FROM food_name_mappings WHERE source_system = %s AND source_name_normalized = %s AND profile_id IS NULL """, (source_system, norm), ) existing = cur.fetchone() raw = source_name_raw.strip() if existing: cur.execute( """ UPDATE food_name_mappings SET food_id = %s, source_name_raw = %s, source = %s, updated_at = NOW() WHERE id = %s """, (food_id, raw, source, existing["id"]), ) return int(existing["id"]) cur.execute( """ INSERT INTO food_name_mappings (source_system, source_name_raw, source_name_normalized, food_id, profile_id, source, updated_at) VALUES (%s, %s, %s, %s, %s, %s, NOW()) RETURNING id """, (source_system, raw, norm, food_id, profile_id, source), ) return int(cur.fetchone()["id"]) def apply_mapping_to_items(cur, profile_id: str, source_name_normalized: str, food_id: str, mapping_id: int) -> int: origin = _value_origin_for_food(cur, food_id) cur.execute( """ UPDATE nutrition_items SET food_id = %s, mapping_id = %s, value_origin = %s, updated_at = NOW() WHERE profile_id = %s AND source_name_normalized = %s """, (food_id, mapping_id, origin, profile_id, source_name_normalized), ) return cur.rowcount or 0 def clear_mapping_from_items(cur, profile_id: str, source_name_normalized: str) -> int: cur.execute( """ UPDATE nutrition_items SET food_id = NULL, mapping_id = NULL, value_origin = 'fddb', updated_at = NOW() WHERE profile_id = %s AND source_name_normalized = %s """, (profile_id, source_name_normalized), ) return cur.rowcount or 0 def _value_origin_for_food(cur, food_id: str) -> str: cur.execute("SELECT catalog_kind FROM food_catalog WHERE id = %s", (food_id,)) row = cur.fetchone() if not row: return "fddb" kind = row["catalog_kind"] if kind == "official_bls": return "bls" return "manual_catalog" def suggest_catalog_foods(cur, query: str, profile_id: str | None, limit: int = 8) -> list[dict]: q = (query or "").strip() if not q: return [] like = f"%{q}%" norm = normalize_food_name(q) cur.execute( """ SELECT id, bls_code, name_de, name_en, catalog_kind, food_group FROM food_catalog WHERE is_active = true AND ( owner_profile_id IS NULL OR owner_profile_id = %s ) AND ( name_de ILIKE %s OR COALESCE(name_en, '') ILIKE %s OR COALESCE(bls_code, '') ILIKE %s OR lower(name_de) = %s ) ORDER BY CASE WHEN lower(name_de) = %s THEN 0 WHEN COALESCE(bls_code, '') ILIKE %s THEN 1 ELSE 2 END, name_de LIMIT %s """, (profile_id, like, like, like, norm, norm, q, limit), ) return [dict(r) for r in cur.fetchall()]