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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user