Gruppen: Obergruppen + Mindestbestaende nur noch je Lagerort (Backend)

Zwei zusammenhaengende Umbauten, weil sie dieselben Stellen betreffen.

Obergruppen: Gruppen bilden jetzt einen gerichteten azyklischen Graphen statt
einer flachen Liste. Eine Gruppe darf unter MEHREREN Obergruppen haengen -
"Grillwurst" unter "Wurst" UND unter "Grillgut"; mit einem einzelnen parent_id
waere genau das nicht abbildbar. Bestand und Mindestbestand einer Gruppe zaehlen
den gesamten Untergraphen, wobei eine ueber zwei Wege erreichbare Untergruppe
nur einmal zaehlt (services/gruppen.py arbeitet durchgaengig mit Mengen).
Product.group_id bleibt unveraendert - ein Artikel haengt weiter an genau einer
Gruppe.

Mindestbestaende: der separate Gesamt-Mindestbestand entfaellt. Er wird zur
Zeile mit location_id NULL ("Ueberall") und ist damit die Wurzel ueber allen
Lagerorten - dieselbe Verrechnung wie bei verschachtelten Orten greift jetzt
auch zwischen Ueberall und Kueche, wodurch derselbe Artikel nicht mehr doppelt
in der Einkaufsliste steht. Alle Werte liegen einheitlich in Basiseinheiten
statt in drei verschiedenen Einheiten nebeneinander; das Umrechnen beim
Umschalten der Erfassungseinheit entfaellt dadurch ersatzlos.

_netted_topups nimmt die Hierarchie jetzt als Parameter und faltet damit
Lagerort-Baum und Gruppen-Graph. Verrechnet wird zwischen zwei Gruppen nur,
wenn die zaehlenden Artikel der Untergruppe eine Teilmenge der Obergruppe sind -
zaehlt die Obergruppe in Kilogramm und die Untergruppe in Stueck, kommt ein Kauf
dort oben nicht an.

Die vierfach kopierte Bestandssumme wandert in Sammelabfragen
(summe_bestand_base), sonst vervielfacht der transitive Teilgraph die Abfragen.

Einmalige Datenwanderung beim Start (Merker in den Einstellungen), 18 neue
Tests - darunter Doppelzaehlung ueber zwei Wege, Ringschutz und die bewusst
offene Grenze bei zwei Obergruppen mit gemeinsamer Untergruppe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-08-15 21:48:14 +02:00
parent df65d9583c
commit eaacfd03e5
16 changed files with 1327 additions and 238 deletions

View File

@@ -0,0 +1,236 @@
"""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