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>
231 lines
9.5 KiB
Python
231 lines
9.5 KiB
Python
"""Einheiten-Umrechnung auf Basis der verwaltbaren Unit-Tabelle.
|
||
|
||
Bestände werden immer in der kanonischen Basiseinheit der jeweiligen Art
|
||
gespeichert: Stück (count), Gramm (weight), Milliliter (volume). Verwaltete
|
||
Einheiten (z.B. Kilogramm, Liter, Pfund) rechnen über ihren Faktor dorthin um.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import NamedTuple
|
||
|
||
from sqlalchemy import func
|
||
from sqlalchemy.orm import Session
|
||
|
||
from ..models import BaseUnit, Group, Product, Unit, UnitKind
|
||
from . import gruppen
|
||
|
||
BASE_OF_KIND: dict[UnitKind, BaseUnit] = {
|
||
UnitKind.count: BaseUnit.piece,
|
||
UnitKind.weight: BaseUnit.gram,
|
||
UnitKind.volume: BaseUnit.milliliter,
|
||
}
|
||
KIND_OF_BASE: dict[BaseUnit, UnitKind] = {v: k for k, v in BASE_OF_KIND.items()}
|
||
|
||
BASE_LABEL: dict[BaseUnit, str] = {
|
||
BaseUnit.piece: "Stück",
|
||
BaseUnit.gram: "Gramm",
|
||
BaseUnit.milliliter: "Milliliter",
|
||
}
|
||
|
||
PACKAGE_TOKENS = {"package", "packung", "pkg", "pack"}
|
||
|
||
# Kürzel/Aliase, die direkt auf eingebaute Einheitennamen zeigen.
|
||
_ALIAS = {
|
||
"g": "gramm", "gram": "gramm", "gramm": "gramm",
|
||
"kg": "kilogramm", "kilogramm": "kilogramm",
|
||
"ml": "milliliter", "milliliter": "milliliter",
|
||
"l": "liter", "liter": "liter",
|
||
"stück": "stück", "stueck": "stück", "st": "stück", "stk": "stück", "piece": "stück",
|
||
}
|
||
|
||
|
||
class ConversionError(ValueError):
|
||
"""Fachlicher Fehler bei der Einheiten-Umrechnung."""
|
||
|
||
|
||
def kind_of_product(product: Product) -> UnitKind:
|
||
return KIND_OF_BASE[product.base_unit]
|
||
|
||
|
||
def find_unit(db: Session, token: str) -> Unit | None:
|
||
t = token.strip().lower()
|
||
t = _ALIAS.get(t, t)
|
||
return db.query(Unit).filter(func.lower(Unit.name) == t).first()
|
||
|
||
|
||
def zweit_faktor(product: Product, ziel: BaseUnit) -> float | None:
|
||
"""Wie viele ``ziel``-Basiseinheiten EINE Basiseinheit des Artikels wert ist.
|
||
|
||
Die Zweiteinheit am Artikel („3 Stück ≙ 250 g") ist die einzige Brücke
|
||
zwischen den sonst strikt getrennten Arten. Sie gilt in BEIDE Richtungen:
|
||
derselbe Eintrag macht aus einem Stück-Artikel 83,333 g je Stück und aus
|
||
einem Gramm-Artikel 0,012 Stück je Gramm.
|
||
|
||
Gleiche Art → 1.0. Ohne (oder mit unbrauchbarer) Brücke → ``None``; dann
|
||
bleibt es bei der strikten Trennung, und der Aufrufer entscheidet, was das
|
||
heisst – ablehnen beim Einlagern, aussortieren beim Gruppenbestand.
|
||
"""
|
||
if product.base_unit == ziel:
|
||
return 1.0
|
||
if product.secondary_base != ziel.value:
|
||
return None # keine Brücke in DIESE Art
|
||
anzahl = product.secondary_count # in der Basiseinheit des Artikels
|
||
menge = product.secondary_amount # in secondary_base
|
||
if not anzahl or not menge or anzahl <= 0 or menge <= 0:
|
||
return None
|
||
# „3 Stück ≙ 250 g" -> 1 Stück = 250/3 g. Bei einem Gramm-Artikel steht
|
||
# dieselbe Zeile als „250 g ≙ 3 Stück" da und liefert 3/250 Stück je Gramm.
|
||
return menge / anzahl
|
||
|
||
|
||
def in_artikel_basis(product: Product, menge: float, quell_base: BaseUnit) -> float | None:
|
||
"""Gegenrichtung: eine Menge in ``quell_base`` in die Artikel-Basiseinheit.
|
||
|
||
Bewusst erst multiplizieren, dann teilen. ``250 * 3 / 250`` ist exakt 3;
|
||
der Umweg ueber den Faktor (``250 / (250/3)``) ergibt 3,0000000000000004 –
|
||
und daran scheitert sonst „den ganzen Bestand auslagern" an der Pruefung in
|
||
``check_out``.
|
||
"""
|
||
if quell_base == product.base_unit:
|
||
return menge
|
||
if product.secondary_base != quell_base.value:
|
||
return None
|
||
anzahl = product.secondary_count
|
||
zweit = product.secondary_amount
|
||
if not anzahl or not zweit or anzahl <= 0 or zweit <= 0:
|
||
return None
|
||
return menge * anzahl / zweit
|
||
|
||
|
||
def to_base(db: Session, product: Product, quantity: float, unit_token: str) -> float:
|
||
"""Rechnet eine Menge (in unit_token) in die Basiseinheit des Produkts um."""
|
||
if quantity <= 0:
|
||
raise ConversionError("Menge muss größer als 0 sein")
|
||
|
||
t = (unit_token or "").strip().lower()
|
||
if t in PACKAGE_TOKENS:
|
||
if not product.package_size or product.package_size <= 0:
|
||
raise ConversionError(
|
||
"Für dieses Produkt ist keine Packungsgröße hinterlegt."
|
||
)
|
||
return quantity * product.package_size
|
||
|
||
unit = find_unit(db, t)
|
||
if unit is None:
|
||
raise ConversionError(f"Unbekannte Einheit: {unit_token}")
|
||
if unit.kind != kind_of_product(product):
|
||
# Artfremde Einheit: geht nur über die Zweiteinheit des Artikels.
|
||
# „250 g" bei einem in Stück geführten Artikel wird zu 3 Stück.
|
||
# Bewusst OHNE Runden – krumme Mengen sind erlaubt, sonst driftete
|
||
# der gebuchte Bestand von der tatsächlichen Entnahme weg.
|
||
ergebnis = in_artikel_basis(product, quantity * unit.factor, BASE_OF_KIND[unit.kind])
|
||
if ergebnis is None:
|
||
raise ConversionError(
|
||
f"Einheit '{unit.name}' passt nicht zur Art des Produkts "
|
||
f"({BASE_LABEL[product.base_unit]}). Mit einer Zweiteinheit am "
|
||
f"Artikel (z.B. „3 Stück ≙ 250 g\") ginge es."
|
||
)
|
||
return ergebnis
|
||
return quantity * unit.factor
|
||
|
||
|
||
def resolve_product_unit(db: Session, unit_id: int) -> tuple[BaseUnit, int]:
|
||
"""Ermittelt zu einer gewählten Einheit die kanonische Basiseinheit + Anzeige-ID."""
|
||
unit = db.get(Unit, unit_id)
|
||
if unit is None:
|
||
raise ConversionError("Einheit nicht gefunden")
|
||
return BASE_OF_KIND[unit.kind], unit.id
|
||
|
||
|
||
def display_unit_info(product: Product) -> tuple[str, float]:
|
||
"""Name und Faktor der Anzeigeeinheit des Produkts (Fallback: Basiseinheit)."""
|
||
if product.display_unit is not None:
|
||
return product.display_unit.name, product.display_unit.factor
|
||
return BASE_LABEL[product.base_unit], 1.0
|
||
|
||
|
||
def article_unit(product: Product) -> tuple[float, str]:
|
||
"""Faktor und Bezeichnung der *Artikeleinheit* – das zählbare Gebinde.
|
||
|
||
Gibt es eine Packungsgröße, ist eine Einheit ein Gebinde (Glas, Packung …),
|
||
sonst die Anzeigeeinheit des Produkts. Nur diese Größe lässt sich über
|
||
verschiedene Artikel hinweg sinnvoll addieren: 3 Gläser + 2 Packungen sind
|
||
5 Einheiten, während 1000 g + 2 Stück keine sinnvolle Summe ergeben.
|
||
"""
|
||
if product.package_size and product.package_size > 0:
|
||
return product.package_size, (product.package_label or "Packung")
|
||
name, factor = display_unit_info(product)
|
||
return (factor or 1.0), name
|
||
|
||
|
||
def article_units(product: Product, quantity_base: float) -> float:
|
||
"""Rechnet eine Menge in Basiseinheiten in Artikeleinheiten um."""
|
||
factor, _ = article_unit(product)
|
||
return quantity_base / factor if factor else quantity_base
|
||
|
||
|
||
class GroupMinContext(NamedTuple):
|
||
divisor: float # Basiseinheiten je Mindestbestand-Einheit
|
||
label: str # Einzahl-Label ("Glas", "Gramm", …)
|
||
is_package: bool # zaehlbares Gebinde (aufrunden) vs. Anzeige-/Basiseinheit
|
||
base_unit: BaseUnit | None
|
||
matching: list[Product] # Produkte, die zur Gruppen-Einheit zaehlen
|
||
# Artikel-ID -> Gruppen-Basiseinheiten je EINER Basiseinheit des Artikels.
|
||
# Fuer gleichartige Artikel 1,0; fuer artfremde der Zweiteinheit-Faktor.
|
||
# Bestandssummen muessen damit GEWICHTET werden (services/stock.py).
|
||
faktoren: dict[int, float]
|
||
|
||
|
||
def group_min_context(group: Group) -> GroupMinContext:
|
||
"""In welcher Einheit der Mindestbestand einer Gruppe zu lesen ist.
|
||
|
||
Eine Gruppe zaehlt in ihrer verwalteten Einheit (``min_stock_unit``) oder – wenn
|
||
ein Gruppen-Gebinde definiert und aktiv ist – in ganzen Packungen. Die
|
||
Packungsgroesse ist ein **Richtwert der Gruppe** (nicht die der einzelnen
|
||
Produkte), weil deren Packungen unterschiedlich gross sein koennen.
|
||
|
||
``matching`` umfasst die Artikel der Gruppe UND ihrer Untergruppen
|
||
(transitiv): „Wurst" zaehlt Wurst, Grillwurst, Salami und alles darunter.
|
||
Die Einheiten-Filterung der OBERgruppe gilt dabei fuer den ganzen
|
||
Teilgraphen.
|
||
|
||
Artfremde Artikel zaehlen mit, WENN sie eine Zweiteinheit in die Art der
|
||
Gruppe tragen: ein in Stueck gefuehrter Artikel mit „3 Stueck ≙ 250 g"
|
||
zaehlt in einer Kilogramm-Gruppe mit 83,333 g je Stueck. Ohne Bruecke bleibt
|
||
er wie bisher aussen vor. Die Umrechnung steht in ``faktoren``.
|
||
"""
|
||
alle = gruppen.produkte(group) # inkl. Untergruppen, ohne Dubletten
|
||
unit = group.min_stock_unit
|
||
if unit is not None:
|
||
base = BASE_OF_KIND[unit.kind]
|
||
matching = []
|
||
faktoren: dict[int, float] = {}
|
||
for p in alle:
|
||
f = zweit_faktor(p, base)
|
||
if f is not None:
|
||
matching.append(p)
|
||
faktoren[p.id] = f
|
||
base_unit: BaseUnit | None = base
|
||
else:
|
||
# Ohne verwaltete Einheit zaehlt die Gruppe roh weiter – dann werden
|
||
# Gramm und Stueck weiterhin ungefiltert addiert (unveraendert).
|
||
matching = alle
|
||
faktoren = {p.id: 1.0 for p in alle}
|
||
base_unit = None
|
||
if group.min_stock_in_packages and group.package_size and group.package_size > 0:
|
||
return GroupMinContext(
|
||
divisor=float(group.package_size),
|
||
label=group.package_label or "Packung",
|
||
is_package=True, base_unit=base_unit, matching=matching, faktoren=faktoren,
|
||
)
|
||
if unit is not None:
|
||
return GroupMinContext(
|
||
divisor=unit.factor or 1.0, label=unit.name, is_package=False,
|
||
base_unit=base_unit, matching=matching, faktoren=faktoren,
|
||
)
|
||
return GroupMinContext(
|
||
divisor=1.0, label="", is_package=False,
|
||
base_unit=None, matching=matching, faktoren=faktoren,
|
||
)
|