Die drei Einheiten-Arten waren bisher strikt getrennt: BASE_OF_KIND bildet count/weight/volume 1:1 auf Stueck/Gramm/Milliliter ab, ohne jeden Faktor dazwischen. Zwei Stellen setzten das durch - to_base lehnte artfremde Einheiten beim Ein-/Auslagern ab, und group_min_context filterte stueckweise gefuehrte Artikel aus einer Kilogramm-Gruppe stillschweigend heraus. Letzteres war der Anlass: eine Gruppe "Wurst" in kg sah Bratwuerste in Stueck gar nicht. Ein Artikel darf jetzt eine Zweiteinheit tragen: "3 Stueck ≙ 250 g". Gespeichert wird das eingegebene PAAR, nicht der Faktor - wer 3 und 250 eintippt, sieht beim naechsten Oeffnen genau das wieder. Das hat auch einen rechnerischen Grund: 250 * 3 / 250 ist exakt 3, der Umweg ueber 250/3 ergibt 3,0000000000000004 und liefe damit gegen die Bestandspruefung beim Auslagern. Der Artikel bleibt in seiner Basiseinheit gefuehrt; die Bruecke ist reine Rechnung. Gruppen zaehlen artfremde Artikel jetzt mit ihrem Faktor mit (GroupMinContext.faktoren), Bestandssummen laufen dafuer je Artikel gewichtet - weiterhin zwei Abfragen, nur mit GROUP BY. Ein-/Auslagern in der Fremdeinheit geht, krumme Mengen werden bewusst gebucht statt gerundet: 100 g sind 1,2 Stueck, und Runden wuerde stumm etwas anderes buchen als angegeben. WICHTIGE KORREKTUR am urspruenglichen Plan: die Teilmengen-Bedingung in _gruppen_bedarfe konnte NICHT bleiben. Sie war bisher zugleich ein Einheiten-Schutz, weil Artikel verschiedener Arten zwangslaeufig disjunkt waren. Mit der Bruecke gilt sie ploetzlich auch zwischen einer Stueck- und einer Gramm-Gruppe - und _netted_topups haette einen Bedarf in Stueck von einem in Gramm abgezogen. Jetzt wird nur noch zwischen Gruppen derselben Basiseinheit verrechnet. Open Food Facts: "3 x 80 g" verlor bisher den Multiplikator, weil der Regex den ersten Zahl-Einheit-Treffer nahm. parse_gebinde liefert jetzt Gesamtmenge UND Stueckzahl und belegt die Zweiteinheit vor; parse_quantity behaelt seinen schmalen Vertrag. 18 neue Tests. Dass test_wrong_kind_rejected und test_einheitenfilter_gilt_auch_fuer_untergruppen unveraendert gruen bleiben, ist selbst der Beleg: ohne Bruecke aendert sich nichts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
177 lines
6.5 KiB
Python
177 lines
6.5 KiB
Python
"""Open Food Facts Lookup-Client."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
from typing import NamedTuple
|
||
|
||
import httpx
|
||
|
||
from .config import get_settings
|
||
|
||
settings = get_settings()
|
||
|
||
# Faktoren, um eine Einheit in die Basiseinheit (Gramm bzw. Milliliter) umzurechnen.
|
||
_UNIT_TO_BASE: dict[str, tuple[str, float]] = {
|
||
"kg": ("gram", 1000.0),
|
||
"g": ("gram", 1.0),
|
||
"mg": ("gram", 0.001),
|
||
"l": ("milliliter", 1000.0),
|
||
"dl": ("milliliter", 100.0),
|
||
"cl": ("milliliter", 10.0),
|
||
"ml": ("milliliter", 1.0),
|
||
}
|
||
|
||
_QUANTITY_RE = re.compile(r"([\d]+(?:[.,]\d+)?)\s*(kg|mg|g|dl|cl|ml|l)\b", re.IGNORECASE)
|
||
|
||
#: „3 x 80 g", „6 × 1,5 l", „4 Stück à 125 g" – der Multiplikator steht vorn,
|
||
#: dazwischen darf ein Zählwort stehen. Bewusst eng gefasst: ein zu weites
|
||
#: Muster macht aus „12 Eier à 53 g" schnell Unsinn, und eine nicht erkannte
|
||
#: Stückzahl ist besser als eine falsch geratene.
|
||
_MULTI_RE = re.compile(
|
||
r"(?P<anzahl>\d+)\s*(?:st(?:ü|ue)ck|stk\.?|pcs?|st\.?)?\s*"
|
||
r"(?:[x×*]|à|a)\s*"
|
||
r"(?P<menge>\d+(?:[.,]\d+)?)\s*(?P<einheit>kg|mg|g|dl|cl|ml|l)\b",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
|
||
class OffMenge(NamedTuple):
|
||
"""Was aus dem OFF-Feld ``quantity`` herauszulesen ist."""
|
||
base_unit: str # "gram" | "milliliter" | "piece"
|
||
package_size: float | None # Gesamtfuellmenge des Gebindes, in Basiseinheiten
|
||
stueck: float | None # zaehlbare Teile im Gebinde (nur bei „3 x 80 g")
|
||
einzelmenge: float | None # Fuellmenge EINES Teils (die 80 g)
|
||
|
||
|
||
def parse_gebinde(quantity: str | None) -> OffMenge:
|
||
"""Gesamtmenge UND einen etwaigen Multiplikator aus ``quantity`` lesen.
|
||
|
||
„3 x 80 g" ist nicht dasselbe wie „240 g": die Packung enthaelt drei
|
||
zaehlbare Riegel. Dieser Multiplikator ging bisher verloren, weil das alte
|
||
Muster den ERSTEN Zahl-Einheit-Treffer nahm – also die 80.
|
||
"""
|
||
if not quantity:
|
||
return OffMenge("piece", None, None, None)
|
||
|
||
multi = _MULTI_RE.search(quantity)
|
||
if multi is not None:
|
||
anzahl = float(multi.group("anzahl"))
|
||
menge = float(multi.group("menge").replace(",", "."))
|
||
base_unit, factor = _UNIT_TO_BASE[multi.group("einheit").lower()]
|
||
einzel = round(menge * factor, 3)
|
||
gesamt = round(anzahl * einzel, 3)
|
||
if anzahl > 0 and gesamt > 0:
|
||
return OffMenge(base_unit, gesamt, anzahl, einzel)
|
||
return OffMenge("piece", None, None, None)
|
||
|
||
einfach = _QUANTITY_RE.search(quantity)
|
||
if einfach is None:
|
||
return OffMenge("piece", None, None, None)
|
||
amount = float(einfach.group(1).replace(",", "."))
|
||
base_unit, factor = _UNIT_TO_BASE[einfach.group(2).lower()]
|
||
gesamt = round(amount * factor, 3)
|
||
return OffMenge(base_unit, gesamt if gesamt > 0 else None, None, None)
|
||
|
||
|
||
def parse_quantity(quantity: str | None) -> tuple[str, float | None]:
|
||
"""Ermittelt Basiseinheit und Packungsgröße aus dem OFF-Feld 'quantity'.
|
||
|
||
Beispiele: '500 g' -> ('gram', 500), '1 kg' -> ('gram', 1000),
|
||
'1,5 l' -> ('milliliter', 1500), '6 Stück' -> ('piece', None).
|
||
|
||
Der schmale Vertrag von frueher; die volle Auskunft (mit Multiplikator)
|
||
liefert ``parse_gebinde``.
|
||
"""
|
||
menge = parse_gebinde(quantity)
|
||
return menge.base_unit, menge.package_size
|
||
|
||
|
||
def _lookup_at(barcode: str, base_url: str, source: str) -> dict | None:
|
||
"""Fragt eine Open-Facts-Instanz (OFF oder Open Products Facts) nach einem Barcode.
|
||
|
||
Beide Dienste teilen sich dieselbe API-Struktur; es unterscheidet sich nur die
|
||
Basis-URL. Gibt ein vorbefülltes Produkt-Dict zurück oder None.
|
||
"""
|
||
# v0-API: liefert bei Treffer status=1 + product, bei Nicht-Treffer status=0
|
||
# (HTTP 200). Die v2-API hat kein status-Feld und antwortet mit HTTP 404,
|
||
# weshalb wir bewusst v0 nutzen.
|
||
url = f"{base_url}/api/v0/product/{barcode}.json"
|
||
try:
|
||
resp = httpx.get(
|
||
url,
|
||
timeout=settings.off_timeout_seconds,
|
||
headers={"User-Agent": "Vorrania-Selfhosted/1.0"},
|
||
follow_redirects=True,
|
||
)
|
||
except httpx.HTTPError:
|
||
return None
|
||
|
||
if resp.status_code != 200:
|
||
return None
|
||
|
||
try:
|
||
data = resp.json()
|
||
except ValueError:
|
||
return None
|
||
if data.get("status") != 1:
|
||
return None
|
||
|
||
product = data.get("product", {})
|
||
name = (
|
||
product.get("product_name_de")
|
||
or product.get("product_name")
|
||
or product.get("generic_name")
|
||
or ""
|
||
).strip()
|
||
if not name:
|
||
return None
|
||
|
||
categories = product.get("categories") or ""
|
||
category_tags = product.get("categories_tags") or []
|
||
|
||
menge = parse_gebinde(product.get("quantity"))
|
||
|
||
return {
|
||
"barcode": barcode,
|
||
"name": name,
|
||
"brand": (product.get("brands") or "").strip() or None,
|
||
"image_url": product.get("image_front_url") or product.get("image_url") or None,
|
||
"base_unit": menge.base_unit,
|
||
"package_size": menge.package_size,
|
||
"quantity_text": product.get("quantity"),
|
||
# „3 x 80 g": die Packung enthaelt drei zaehlbare Teile. Als VORSCHLAG
|
||
# fuer die Zweiteinheit mitgegeben („240 g ≙ 3 Stueck"), nicht gesetzt –
|
||
# bestaetigen soll es der Nutzer, OFF-Angaben sind nicht immer sauber.
|
||
"secondary_base": "piece" if menge.stueck else None,
|
||
"secondary_count": menge.package_size if menge.stueck else None,
|
||
"secondary_amount": menge.stueck,
|
||
# Nur fuer den Hinweistext „3 × 80 g".
|
||
"unit_amount": menge.einzelmenge,
|
||
"category_suggestion": categories.split(",")[0].strip() if categories else None,
|
||
"category_tags": category_tags,
|
||
"source": source,
|
||
"off_raw": json.dumps(product)[:20000],
|
||
}
|
||
|
||
|
||
def lookup_barcode(barcode: str, prefer: str | None = None) -> dict | None:
|
||
"""Sucht einen Barcode in den offenen Datenbanken.
|
||
|
||
Beim Scannen steht die Kategorie noch nicht fest, deshalb werden beide Quellen
|
||
der Reihe nach befragt: Lebensmittel (Open Food Facts) und allgemeine Produkte
|
||
(Open Products Facts). ``prefer="object"`` stellt die allgemeine Produkt-DB nach
|
||
vorn (z.B. beim erneuten Abgleich eines Gegenstands), sonst gewinnt Essen.
|
||
"""
|
||
off = (settings.off_base_url, "off")
|
||
opf = (settings.opf_base_url, "opf")
|
||
reihenfolge = [opf, off] if prefer == "object" else [off, opf]
|
||
for base_url, source in reihenfolge:
|
||
if not base_url:
|
||
continue
|
||
treffer = _lookup_at(barcode, base_url, source)
|
||
if treffer is not None:
|
||
return treffer
|
||
return None
|