Files
Vorrania/backend/tests/test_zweiteinheit.py
Scarriffle 9f3225113e 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>
2026-08-16 00:38:13 +02:00

245 lines
7.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)