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:
@@ -54,6 +54,50 @@ def find_unit(db: Session, token: str) -> Unit | None:
|
||||
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:
|
||||
@@ -71,10 +115,18 @@ def to_base(db: Session, product: Product, quantity: float, unit_token: str) ->
|
||||
if unit is None:
|
||||
raise ConversionError(f"Unbekannte Einheit: {unit_token}")
|
||||
if unit.kind != kind_of_product(product):
|
||||
raise ConversionError(
|
||||
f"Einheit '{unit.name}' passt nicht zur Art des Produkts "
|
||||
f"({BASE_LABEL[product.base_unit]})."
|
||||
)
|
||||
# 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
|
||||
|
||||
|
||||
@@ -119,6 +171,10 @@ class GroupMinContext(NamedTuple):
|
||||
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:
|
||||
@@ -132,23 +188,43 @@ def group_min_context(group: Group) -> GroupMinContext:
|
||||
``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 – zaehlt „Wurst" in Kilogramm, bleiben stueckweise gefuehrte
|
||||
Artikel einer Untergruppe aussen vor, genau wie ein direkt zugeordneter.
|
||||
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 = [p for p in alle if p.base_unit == base]
|
||||
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(
|
||||
float(group.package_size), group.package_label or "Packung",
|
||||
True, base_unit, matching,
|
||||
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(unit.factor or 1.0, unit.name, False, base_unit, matching)
|
||||
return GroupMinContext(1.0, "", False, None, matching)
|
||||
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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user