"""Ober- und Untergruppen: Bestand, Verrechnung und Ringschutz. Gruppen bilden einen gerichteten azyklischen Graphen, keinen Baum: „Grillwurst" haengt unter „Wurst" UND unter „Grillgut". Der wichtigste Test hier ist deshalb ``test_zwei_wege_zaehlen_nur_einmal`` – ein Baum-Walk statt einer Mengenbildung wuerde den Bestand einer ueber zwei Wege erreichbaren Untergruppe verdoppeln. """ import pytest from fastapi import HTTPException from app.models import ( BaseUnit, Group, GroupLocationMinStock, Location, Lot, Product, Role, Unit, User, ) from app.routers.groups import _group_to_out, create_group, delete_group, update_group from app.routers.views import group_shopping_list, shopping_list_by_location from app.schemas import GroupCreate, GroupUpdate from app.services.min_stock import schreibe_ueberall @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 _gruppe(db, name: str, *, eltern: list[Group] | None = None) -> Group: g = Group(name=name, min_stock_unit=_gramm(db)) if eltern: g.parents = list(eltern) db.add(g) db.commit() db.refresh(g) return g def _artikel(db, name: str, gruppe: Group, bestand: float = 0.0, ort=None) -> Product: p = Product(name=name, base_unit=BaseUnit.gram, group=gruppe) 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 def _ueberall(db, besitzer, menge_base: float) -> None: schreibe_ueberall(db, besitzer, menge_base) db.commit() def test_bestand_zaehlt_untergruppen_transitiv(db, user): """Bestand nur im Blatt – die Obergruppe zwei Ebenen darueber sieht ihn.""" wurst = _gruppe(db, "Wurst") grillwurst = _gruppe(db, "Grillwurst", eltern=[wurst]) bratwurst = _gruppe(db, "Bratwurst", eltern=[grillwurst]) _artikel(db, "Bell Bratwurst", bratwurst, bestand=800) assert _group_to_out(db, bratwurst).stock == pytest.approx(800) assert _group_to_out(db, grillwurst).stock == pytest.approx(800) assert _group_to_out(db, wurst).stock == pytest.approx(800) # product_count zaehlt transitiv, direct_product_count nur die eigenen. oben = _group_to_out(db, wurst) assert oben.product_count == 1 assert oben.direct_product_count == 0 def test_zwei_wege_zaehlen_nur_einmal(db, user): """Grillwurst haengt unter Wurst UND Grillgut, beide unter Fleisch. „Fleisch" erreicht Grillwurst ueber zwei Wege – der Bestand darf trotzdem nur einmal zaehlen. """ fleisch = _gruppe(db, "Fleisch") wurst = _gruppe(db, "Wurst", eltern=[fleisch]) grillgut = _gruppe(db, "Grillgut", eltern=[fleisch]) grillwurst = _gruppe(db, "Grillwurst", eltern=[wurst, grillgut]) _artikel(db, "Bell Grillwurst", grillwurst, bestand=500) assert _group_to_out(db, grillwurst).stock == pytest.approx(500) assert _group_to_out(db, wurst).stock == pytest.approx(500) assert _group_to_out(db, grillgut).stock == pytest.approx(500) # Der eigentliche Punkt: NICHT 1000. assert _group_to_out(db, fleisch).stock == pytest.approx(500) assert _group_to_out(db, fleisch).product_count == 1 def test_ring_wird_abgelehnt(db, user): """Weder sich selbst noch einer eigenen Untergruppe unterordnen.""" wurst = _gruppe(db, "Wurst") grillwurst = _gruppe(db, "Grillwurst", eltern=[wurst]) with pytest.raises(HTTPException) as fehler: update_group(group_id=wurst.id, payload=GroupUpdate(parent_ids=[grillwurst.id]), db=db, _=user) assert fehler.value.status_code == 409 with pytest.raises(HTTPException): update_group(group_id=wurst.id, payload=GroupUpdate(parent_ids=[wurst.id]), db=db, _=user) def test_obergruppe_beim_anlegen_setzen(db, user): """parent_ids wirkt auch schon beim Anlegen (braucht die frische ID).""" wurst = _gruppe(db, "Wurst") out = create_group( payload=GroupCreate(name="Salami", parent_ids=[wurst.id]), db=db, _=user ) assert out.parent_ids == [wurst.id] assert _group_to_out(db, wurst).child_ids == [out.id] def test_bedarf_ober_und_untergruppe_wird_verrechnet(db, user): """Wurst 5 kg, Grillwurst 2 kg, 1 kg vorhanden. Fuer Grillwurst fehlt 1 kg. Das deckt bei Wurst mit, dort fehlen also 5 - (1 vorhanden + 1 gekauft) = 3 kg – nicht 4. """ wurst = _gruppe(db, "Wurst") grillwurst = _gruppe(db, "Grillwurst", eltern=[wurst]) _artikel(db, "Bell Grillwurst", grillwurst, bestand=1000) _ueberall(db, wurst, 5000) _ueberall(db, grillwurst, 2000) nach_name = {i.name: i for i in group_shopping_list(db=db, _=user)} assert nach_name["Grillwurst"].deficit == pytest.approx(1000) assert nach_name["Wurst"].deficit == pytest.approx(3000) assert nach_name["Wurst"].subgroup_count == 1 def test_untergruppen_kauf_deckt_obergruppe_ganz(db, user): """Deckt der Kauf fuer die Untergruppe alles, verschwindet die Obergruppe.""" wurst = _gruppe(db, "Wurst") grillwurst = _gruppe(db, "Grillwurst", eltern=[wurst]) _artikel(db, "Bell Grillwurst", grillwurst) _ueberall(db, wurst, 2000) _ueberall(db, grillwurst, 2000) namen = {i.name for i in group_shopping_list(db=db, _=user)} assert namen == {"Grillwurst"} def test_ueberlappung_bleibt_stehen(db, user): """Bekannte Grenze, absichtlich festgehalten. „Wurst" und „Grillgut" sind keine Vorfahren voneinander, teilen sich aber „Grillwurst". Ohne eigenen Mindestbestand auf der gemeinsamen Untergruppe gibt es keinen Schluessel, ueber den verrechnet werden koennte – beide Bedarfe stehen einzeln da, obwohl ein Kauf beide decken wuerde. """ wurst = _gruppe(db, "Wurst") grillgut = _gruppe(db, "Grillgut") grillwurst = _gruppe(db, "Grillwurst", eltern=[wurst, grillgut]) _artikel(db, "Bell Grillwurst", grillwurst) _ueberall(db, wurst, 2000) _ueberall(db, grillgut, 2000) nach_name = {i.name: i for i in group_shopping_list(db=db, _=user)} assert nach_name["Wurst"].deficit == pytest.approx(2000) assert nach_name["Grillgut"].deficit == pytest.approx(2000) def test_einheitenfilter_gilt_auch_fuer_untergruppen(db, user): """Eine Obergruppe in Gramm zaehlt stueckweise Artikel der Untergruppe nicht.""" stueck = db.query(Unit).filter(Unit.name == "Stück").one() wurst = _gruppe(db, "Wurst") # zaehlt in Gramm dosen = Group(name="Wurstdosen", min_stock_unit=stueck, parents=[wurst]) db.add(dosen) db.commit() _artikel(db, "Wurst im Glas", wurst, bestand=500) p = Product(name="Wurstdose", base_unit=BaseUnit.piece, group=dosen) db.add(p) db.flush() db.add(Lot(product_id=p.id, quantity=4)) db.commit() # Die 4 Stueck zaehlen bei „Wurst" (Gramm) nicht mit. assert _group_to_out(db, wurst).stock == pytest.approx(500) assert _group_to_out(db, dosen).stock == pytest.approx(4) def test_gruppenbedarf_je_ort_verrechnet_beide_hierarchien(db, user): """Kauf fuer die Untergruppe im Unterort deckt die Obergruppe im Oberort.""" haus = Location(name="Haus") db.add(haus) db.flush() kueche = Location(name="Küche", parent_id=haus.id) db.add(kueche) db.commit() wurst = _gruppe(db, "Wurst") grillwurst = _gruppe(db, "Grillwurst", eltern=[wurst]) _artikel(db, "Bell Grillwurst", grillwurst, bestand=1000, ort=kueche) db.add_all([ GroupLocationMinStock(group_id=wurst.id, location_id=haus.id, min_stock=5000), GroupLocationMinStock(group_id=grillwurst.id, location_id=kueche.id, min_stock=2000), ]) db.commit() nach_ort = {n.location_name: n for n in shopping_list_by_location(db=db, _=user)} kueche_bedarf = {g.name: g for g in nach_ort["Küche"].groups} haus_bedarf = {g.name: g for g in nach_ort["Haus"].groups} assert kueche_bedarf["Grillwurst"].deficit == pytest.approx(1000) # 5000 - (1000 vorhanden + 1000 fuer die Kueche gekauft) assert haus_bedarf["Wurst"].deficit == pytest.approx(3000) def test_loeschen_einer_gruppe_mit_kindern(db, user): """Untergruppen bleiben bestehen und verlieren nur die Verbindung.""" wurst = _gruppe(db, "Wurst") grillwurst = _gruppe(db, "Grillwurst", eltern=[wurst]) p = _artikel(db, "Bell Grillwurst", grillwurst, bestand=500) delete_group(group_id=wurst.id, db=db, _=user) db.refresh(grillwurst) assert db.get(Group, wurst.id) is None assert _group_to_out(db, grillwurst).parent_ids == [] db.refresh(p) assert p.group_id == grillwurst.id