195 lines
5.9 KiB
Python
195 lines
5.9 KiB
Python
|
|
"""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()]
|