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>
This commit is contained in:
Scarriffle
2026-08-16 00:38:13 +02:00
parent f33054c534
commit 9f3225113e
14 changed files with 675 additions and 92 deletions

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import json
import re
from typing import NamedTuple
import httpx
@@ -24,22 +25,67 @@ _UNIT_TO_BASE: dict[str, tuple[str, float]] = {
_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``.
"""
if not quantity:
return "piece", None
match = _QUANTITY_RE.search(quantity)
if not match:
return "piece", None
amount = float(match.group(1).replace(",", "."))
base_unit, factor = _UNIT_TO_BASE[match.group(2).lower()]
package_size = round(amount * factor, 3)
return base_unit, (package_size if package_size > 0 else None)
menge = parse_gebinde(quantity)
return menge.base_unit, menge.package_size
def _lookup_at(barcode: str, base_url: str, source: str) -> dict | None:
@@ -85,16 +131,24 @@ def _lookup_at(barcode: str, base_url: str, source: str) -> dict | None:
categories = product.get("categories") or ""
category_tags = product.get("categories_tags") or []
base_unit, package_size = parse_quantity(product.get("quantity"))
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": base_unit,
"package_size": package_size,
"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,