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:
Scarriffle
2026-08-16 00:38:13 +02:00
parent f33054c534
commit 9f3225113e
14 changed files with 675 additions and 92 deletions

View File

@@ -143,3 +143,17 @@ def test_ring_aus_der_sicherung_wird_abgewiesen(db, user):
# Eine der beiden Kanten greift, die andere wird als Ring abgewiesen.
assert not (a.parents and b.parents)
assert any("Ring" in f for f in ergebnis.get("errors", []))
def test_sicherung_nimmt_zweiteinheit_mit(db, user):
"""Ohne das ginge die Brücke beim Wiederherstellen stillschweigend verloren."""
p = Product(name="Bratwurst", base_unit=BaseUnit.piece,
secondary_base="gram", secondary_count=3, secondary_amount=250)
db.add(p)
db.commit()
daten = json.loads(_sicherung(db, user))
artikel = daten["products"][0]
assert artikel["secondary_base"] == "gram"
assert artikel["secondary_count"] == 3
assert artikel["secondary_amount"] == 250

View File

@@ -1,4 +1,4 @@
from app.off import parse_quantity
from app.off import parse_gebinde, parse_quantity
def test_grams():
@@ -22,3 +22,31 @@ def test_unparsable_is_piece():
assert parse_quantity(None) == ("piece", None)
assert parse_quantity("6 Stück") == ("piece", None)
assert parse_quantity("") == ("piece", None)
def test_multiplikator_wird_erkannt():
"""„3 x 80 g" ist nicht dasselbe wie „240 g": drei zaehlbare Teile."""
assert parse_gebinde("3 x 80 g") == ("gram", 240.0, 3.0, 80.0)
def test_multiplikator_liter():
assert parse_gebinde("6 × 1,5 l") == ("milliliter", 9000.0, 6.0, 1500.0)
def test_multiplikator_mit_a_akzent():
assert parse_gebinde("4 Stück à 125 g") == ("gram", 500.0, 4.0, 125.0)
def test_ohne_multiplikator_unveraendert():
assert parse_gebinde("500 g") == ("gram", 500.0, None, None)
assert parse_quantity("500 g") == ("gram", 500)
def test_zaehlwort_ohne_muster_bleibt_grenze():
"""Bekannte Grenze: „12 Eier à 53 g" faellt auf die Einzelmenge zurueck.
Das Muster kennt nur eine kurze Liste von Zaehlwoertern. Ein generisches
Wort wuerde es zu weit oeffnen lieber eine nicht erkannte Stueckzahl als
eine falsch geratene.
"""
assert parse_gebinde("12 Eier à 53 g").stueck is None

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