"""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)