Files
Vorrania/backend/app/off.py
Scarriffle 9f3225113e Zweiteinheit am Artikel: Bruecke zwischen Stueck, Gramm und Milliliter
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>
2026-08-16 00:38:13 +02:00

177 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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