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:
244
backend/tests/test_zweiteinheit.py
Normal file
244
backend/tests/test_zweiteinheit.py
Normal file
@@ -0,0 +1,244 @@
|
||||
"""Zweiteinheit am Artikel: die Brücke zwischen den Einheiten-Arten.
|
||||
|
||||
Der Artikel bleibt in seiner Basiseinheit geführt (Stück) und trägt zusätzlich
|
||||
eine Äquivalenz zur anderen Art („3 Stück ≙ 250 g"). Erst dadurch zählt er in
|
||||
einer Gramm-Gruppe mit und lässt sich in Gramm ein- und auslagern. Das Gebinde
|
||||
bleibt davon unberührt.
|
||||
|
||||
Ohne Brücke bleibt alles beim Alten – das halten
|
||||
``test_conversion.py::test_wrong_kind_rejected`` und
|
||||
``test_gruppen_hierarchie.py::test_einheitenfilter_gilt_auch_fuer_untergruppen``
|
||||
fest, die beide unverändert grün bleiben.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models import BaseUnit, Group, Location, Lot, Product, Role, Unit, User
|
||||
from app.routers.groups import _group_to_out
|
||||
from app.services.conversion import (
|
||||
ConversionError,
|
||||
group_min_context,
|
||||
in_artikel_basis,
|
||||
to_base,
|
||||
zweit_faktor,
|
||||
)
|
||||
from app.services.stock import (
|
||||
check_out,
|
||||
current_stock,
|
||||
summe_bestand_gewichtet,
|
||||
summe_bestand_im_subtree_gewichtet,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def user(db):
|
||||
person = User(username="tester", password_hash="x", role=Role.admin)
|
||||
db.add(person)
|
||||
db.commit()
|
||||
db.refresh(person)
|
||||
return person
|
||||
|
||||
|
||||
def _gramm(db) -> Unit:
|
||||
return db.query(Unit).filter(Unit.name == "Gramm").one()
|
||||
|
||||
|
||||
def _kilo(db) -> Unit:
|
||||
return db.query(Unit).filter(Unit.name == "Kilogramm").one()
|
||||
|
||||
|
||||
def _riegel(db, *, bruecke: bool = True, bestand: float = 0.0, ort=None) -> Product:
|
||||
"""Ein in STÜCK geführter Artikel, wahlweise mit „3 Stück ≙ 250 g"."""
|
||||
p = Product(
|
||||
name="Bratwurst",
|
||||
base_unit=BaseUnit.piece,
|
||||
secondary_base="gram" if bruecke else None,
|
||||
secondary_count=3 if bruecke else None,
|
||||
secondary_amount=250 if bruecke else None,
|
||||
)
|
||||
db.add(p)
|
||||
db.flush()
|
||||
if bestand:
|
||||
db.add(Lot(product_id=p.id, quantity=bestand,
|
||||
location_id=ort.id if ort is not None else None))
|
||||
db.commit()
|
||||
return p
|
||||
|
||||
|
||||
# ---- Die Brücke selbst ----
|
||||
|
||||
def test_bruecke_beide_richtungen(db):
|
||||
p = _riegel(db)
|
||||
assert zweit_faktor(p, BaseUnit.gram) == pytest.approx(250 / 3)
|
||||
assert zweit_faktor(p, BaseUnit.piece) == 1.0
|
||||
# In eine DRITTE Art gibt es keine Antwort.
|
||||
assert zweit_faktor(p, BaseUnit.milliliter) is None
|
||||
# Gegenrichtung: 250 g sind exakt 3 Stück.
|
||||
assert in_artikel_basis(p, 250, BaseUnit.gram) == pytest.approx(3)
|
||||
|
||||
|
||||
def test_ohne_bruecke_kein_faktor(db):
|
||||
p = _riegel(db, bruecke=False)
|
||||
assert zweit_faktor(p, BaseUnit.gram) is None
|
||||
assert in_artikel_basis(p, 250, BaseUnit.gram) is None
|
||||
|
||||
|
||||
def test_unvollstaendige_bruecke_zaehlt_nicht(db):
|
||||
"""Halbe oder widersprüchliche Angaben sind keine Brücke – und kein Absturz."""
|
||||
p = _riegel(db)
|
||||
p.secondary_count = 0
|
||||
db.commit()
|
||||
assert zweit_faktor(p, BaseUnit.gram) is None # keine Division durch Null
|
||||
|
||||
p.secondary_count = 3
|
||||
p.secondary_amount = None
|
||||
db.commit()
|
||||
assert zweit_faktor(p, BaseUnit.gram) is None
|
||||
|
||||
# Brücke auf die EIGENE Art bringt nichts zu übersetzen.
|
||||
p.secondary_base = "piece"
|
||||
p.secondary_amount = 5
|
||||
db.commit()
|
||||
assert zweit_faktor(p, BaseUnit.gram) is None
|
||||
|
||||
|
||||
# ---- Ein- und Auslagern ----
|
||||
|
||||
def test_einlagern_in_der_fremdeinheit(db):
|
||||
p = _riegel(db)
|
||||
assert to_base(db, p, 250, "Gramm") == pytest.approx(3)
|
||||
assert to_base(db, p, 0.25, "kg") == pytest.approx(3)
|
||||
# Die eigene Art bleibt unberührt.
|
||||
assert to_base(db, p, 2, "Stück") == pytest.approx(2)
|
||||
|
||||
|
||||
def test_ohne_bruecke_bleibt_gesperrt(db):
|
||||
p = _riegel(db, bruecke=False)
|
||||
with pytest.raises(ConversionError):
|
||||
to_base(db, p, 250, "Gramm")
|
||||
|
||||
|
||||
def test_krumme_menge_wird_gebucht_nicht_gerundet(db):
|
||||
"""100 g bei 83,33 g je Stück sind 1,2 Stück – genau das wird gebucht."""
|
||||
p = _riegel(db)
|
||||
assert to_base(db, p, 100, "Gramm") == pytest.approx(1.2)
|
||||
|
||||
|
||||
def test_auslagern_trifft_den_bestand_genau(db, user):
|
||||
"""Der ganze Bestand muss sich in der Fremdeinheit auslagern lassen.
|
||||
|
||||
Sichert das gespeicherte PAAR gegen einen gerundeten Faktor ab: der Umweg
|
||||
ueber 250/3 ergaebe 3,0000000000000004 und liefe gegen die Bestandspruefung.
|
||||
"""
|
||||
p = _riegel(db, bestand=3)
|
||||
check_out(db, p, 250, "Gramm", user) # darf nicht an „zu wenig Bestand" scheitern
|
||||
db.commit()
|
||||
assert current_stock(db, p.id) == pytest.approx(0)
|
||||
|
||||
|
||||
# ---- Gruppen ----
|
||||
|
||||
def _wurst_gruppe(db) -> Group:
|
||||
g = Group(name="Wurst", min_stock_unit=_kilo(db))
|
||||
db.add(g)
|
||||
db.commit()
|
||||
db.refresh(g)
|
||||
return g
|
||||
|
||||
|
||||
def test_gruppe_in_kilogramm_zaehlt_stueck_artikel_mit(db):
|
||||
"""6 Stück à 83,33 g sind 500 g – zusammen mit 500 g Salami genau 1 kg."""
|
||||
gruppe = _wurst_gruppe(db)
|
||||
riegel = _riegel(db, bestand=6)
|
||||
riegel.group_id = gruppe.id
|
||||
salami = Product(name="Salami", base_unit=BaseUnit.gram, group_id=gruppe.id)
|
||||
db.add(salami)
|
||||
db.flush()
|
||||
db.add(Lot(product_id=salami.id, quantity=500))
|
||||
db.commit()
|
||||
|
||||
assert _group_to_out(db, gruppe).stock == pytest.approx(1.0) # in Kilogramm
|
||||
|
||||
|
||||
def test_gruppe_filtert_artikel_ohne_bruecke_weiter(db):
|
||||
"""Ohne Zweiteinheit bleibt der Stück-Artikel aussen vor – wie bisher."""
|
||||
gruppe = _wurst_gruppe(db)
|
||||
riegel = _riegel(db, bruecke=False, bestand=6)
|
||||
riegel.group_id = gruppe.id
|
||||
salami = Product(name="Salami", base_unit=BaseUnit.gram, group_id=gruppe.id)
|
||||
db.add(salami)
|
||||
db.flush()
|
||||
db.add(Lot(product_id=salami.id, quantity=500))
|
||||
db.commit()
|
||||
|
||||
assert _group_to_out(db, gruppe).stock == pytest.approx(0.5)
|
||||
# ``product_count`` zaehlt weiterhin ALLE Artikel im Untergraphen; wer
|
||||
# mitrechnet, steht in ``ctx.matching``.
|
||||
assert [p.name for p in group_min_context(gruppe).matching] == ["Salami"]
|
||||
|
||||
|
||||
def test_faktoren_stehen_je_artikel_im_kontext(db):
|
||||
gruppe = _wurst_gruppe(db)
|
||||
riegel = _riegel(db, bestand=6)
|
||||
riegel.group_id = gruppe.id
|
||||
salami = Product(name="Salami", base_unit=BaseUnit.gram, group_id=gruppe.id)
|
||||
db.add(salami)
|
||||
db.commit()
|
||||
|
||||
ctx = group_min_context(gruppe)
|
||||
assert ctx.faktoren[riegel.id] == pytest.approx(250 / 3)
|
||||
assert ctx.faktoren[salami.id] == 1.0
|
||||
|
||||
|
||||
# ---- Gewichtete Summen ----
|
||||
|
||||
def test_gewichtete_summe_nutzt_je_artikel_eigenen_faktor(db):
|
||||
"""Zwei Artikel, zwei verschiedene Faktoren – kein gemeinsamer."""
|
||||
a = _riegel(db, bestand=6) # 6 Stück
|
||||
b = Product(name="Salami", base_unit=BaseUnit.gram)
|
||||
db.add(b)
|
||||
db.flush()
|
||||
db.add(Lot(product_id=b.id, quantity=500))
|
||||
db.commit()
|
||||
|
||||
faktoren = {a.id: 250 / 3, b.id: 1.0}
|
||||
assert summe_bestand_gewichtet(db, [a, b], faktoren) == pytest.approx(1000)
|
||||
# Ohne Faktoren wird roh addiert (6 + 500).
|
||||
assert summe_bestand_gewichtet(db, [a, b], None) == pytest.approx(506)
|
||||
|
||||
|
||||
def test_gewichtete_summe_im_subtree(db):
|
||||
haus = Location(name="Haus")
|
||||
db.add(haus)
|
||||
db.flush()
|
||||
kueche = Location(name="Küche", parent_id=haus.id)
|
||||
db.add(kueche)
|
||||
db.commit()
|
||||
|
||||
a = _riegel(db, bestand=6, ort=kueche)
|
||||
# Zweite Charge ohne Ort: zaehlt im Gesamtbestand, aber in keinem Subtree.
|
||||
db.add(Lot(product_id=a.id, quantity=3, location_id=None))
|
||||
db.commit()
|
||||
|
||||
faktoren = {a.id: 250 / 3}
|
||||
assert summe_bestand_im_subtree_gewichtet(db, [a], haus.id, faktoren) == pytest.approx(500)
|
||||
assert summe_bestand_gewichtet(db, [a], faktoren) == pytest.approx(750)
|
||||
|
||||
|
||||
# ---- Mindestbestand in der Zweiteinheit ----
|
||||
|
||||
def test_mindestbestand_in_der_zweiteinheit_anzeigen(db):
|
||||
"""„Mindestens 250 g" an einem stückweise geführten Artikel."""
|
||||
from app.crud import product_to_out
|
||||
from app.services.min_stock import schreibe_ueberall
|
||||
|
||||
p = _riegel(db)
|
||||
p.min_stock_unit_id = _gramm(db).id
|
||||
schreibe_ueberall(db, p, 3) # 3 Stück, in Basiseinheiten gespeichert
|
||||
db.commit()
|
||||
|
||||
out = product_to_out(db, p)
|
||||
assert out.min_stock == pytest.approx(3) # Speicherung unveraendert
|
||||
assert out.min_stock_display == pytest.approx(250) # Anzeige in Gramm
|
||||
assert out.min_stock_unit_label == "Gramm"
|
||||
assert out.secondary_factor == pytest.approx(250 / 3)
|
||||
Reference in New Issue
Block a user