diff --git a/backend/app/crud.py b/backend/app/crud.py index 9f8e201..406f3a1 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -12,6 +12,7 @@ from sqlalchemy.orm import Session from .models import Barcode, Category, CategoryTracking, Item, Lot, Product, ProductImage from .schemas import BarcodeOut, LocationMinStockOut, ProductOut from .services.conversion import KIND_OF_BASE, display_unit_info +from .services.min_stock import UEBERALL_NAME, lies_ueberall from .services.stock import current_stock, location_subtree_stock_base @@ -68,26 +69,38 @@ def product_to_out(db: Session, product: Product) -> ProductOut: out.unit_name = name out.unit_factor = factor - # Mindestbestand in der Einheit anzeigen, in der er erfasst wurde. - if product.min_stock is not None: + # „Ueberall" (Ort NULL) ist der frueher separate Gesamt-Mindestbestand. + # ``out.min_stock`` bleibt im Vertrag – Dashboards, Einkaufsliste und App + # lesen es unveraendert weiter, es kommt nur aus einer anderen Quelle. + out.min_stock = lies_ueberall(product.location_min_stocks) + if out.min_stock is not None: if product.min_stock_in_packages and product.package_size: - out.min_stock_display = product.min_stock / product.package_size + out.min_stock_display = out.min_stock / product.package_size out.min_stock_unit_label = "Pkg" elif product.min_stock_unit is not None: - out.min_stock_display = product.min_stock / product.min_stock_unit.factor + out.min_stock_display = out.min_stock / product.min_stock_unit.factor out.min_stock_unit_label = product.min_stock_unit.name else: - out.min_stock_display = product.min_stock / factor + out.min_stock_display = out.min_stock / factor out.min_stock_unit_label = name out.location_min_stocks = [ LocationMinStockOut( location_id=e.location_id, - location_name=e.location.name if e.location else None, + location_name=UEBERALL_NAME if e.location_id is None else ( + e.location.name if e.location else None + ), min_stock=e.min_stock, - stock=location_subtree_stock_base(db, product, e.location_id), + stock=( + out.stock + if e.location_id is None + else location_subtree_stock_base(db, product, e.location_id) + ), + ) + # „Ueberall" zuerst, danach nach Anlagereihenfolge. + for e in sorted( + product.location_min_stocks, key=lambda x: (x.location_id is not None, x.id) ) - for e in sorted(product.location_min_stocks, key=lambda x: x.id) ] return out @@ -151,24 +164,33 @@ def products_to_out_bulk(db: Session, products: list[Product]) -> list[ProductOu name, factor = display_unit_info(product) o.unit_name = name o.unit_factor = factor - if product.min_stock is not None: + o.min_stock = lies_ueberall(product.location_min_stocks) + if o.min_stock is not None: if product.min_stock_in_packages and product.package_size: - o.min_stock_display = product.min_stock / product.package_size + o.min_stock_display = o.min_stock / product.package_size o.min_stock_unit_label = "Pkg" elif product.min_stock_unit is not None: - o.min_stock_display = product.min_stock / product.min_stock_unit.factor + o.min_stock_display = o.min_stock / product.min_stock_unit.factor o.min_stock_unit_label = product.min_stock_unit.name else: - o.min_stock_display = product.min_stock / factor + o.min_stock_display = o.min_stock / factor o.min_stock_unit_label = name o.location_min_stocks = [ LocationMinStockOut( location_id=e.location_id, - location_name=e.location.name if e.location else None, + location_name=UEBERALL_NAME if e.location_id is None else ( + e.location.name if e.location else None + ), min_stock=e.min_stock, - stock=location_subtree_stock_base(db, product, e.location_id), + stock=( + o.stock + if e.location_id is None + else location_subtree_stock_base(db, product, e.location_id) + ), + ) + for e in sorted( + product.location_min_stocks, key=lambda x: (x.location_id is not None, x.id) ) - for e in sorted(product.location_min_stocks, key=lambda x: x.id) ] out.append(o) return out diff --git a/backend/app/main.py b/backend/app/main.py index 697fbe0..82ed310 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -35,6 +35,7 @@ from .seed import ( ensure_first_admin, ) from .services.group_codes import backfill as backfill_group_codes +from .services.min_stock import migriere_mindestbestaende from .services.stock import consolidate_duplicate_lots settings = get_settings() @@ -192,6 +193,17 @@ def _ensure_schema() -> None: # Kaufpreis (in Rappen/Cent) und Währung am Einzelstück. "ALTER TABLE items ADD COLUMN IF NOT EXISTS price_cents INTEGER", "ALTER TABLE items ADD COLUMN IF NOT EXISTS currency VARCHAR(3)", + # Mindestbestände hängen nur noch an Lagerorten; location_id NULL ist + # „Überall". DROP NOT NULL ist auf einer bereits nullable Spalte ein + # No-Op, der Aufruf also wiederholbar. + "ALTER TABLE product_location_min_stock ALTER COLUMN location_id DROP NOT NULL", + "ALTER TABLE group_location_min_stock ALTER COLUMN location_id DROP NOT NULL", + # Postgres zählt NULLs in UNIQUE als verschieden – die vorhandene + # Beschränkung verhindert also keine zwei „Überall"-Zeilen. Teilindex. + "CREATE UNIQUE INDEX IF NOT EXISTS uq_prod_ueberall_min " + "ON product_location_min_stock (product_id) WHERE location_id IS NULL", + "CREATE UNIQUE INDEX IF NOT EXISTS uq_group_ueberall_min " + "ON group_location_min_stock (group_id) WHERE location_id IS NULL", ] with engine.begin() as conn: # Zuerst die Lagerort-ID auf den Code umstellen (einmalig, idempotent), @@ -226,6 +238,9 @@ async def lifespan(app: FastAPI): ensure_first_admin(db) # Codes bestehender Gruppen-Zuordnungen nachziehen. backfill_group_codes(db) + # Gesamt-Mindestbestände zu „Überall"-Zeilen machen und alle Werte auf + # Basiseinheiten umstellen (einmalig, mit Merker in den Einstellungen). + migriere_mindestbestaende(db) # Bereits vorhandene Dubletten (gleicher Artikel + MHD + Lagerort) # einmalig zusammenfassen – ab jetzt geschieht das beim Umlagern selbst. consolidate_duplicate_lots(db) diff --git a/backend/app/models.py b/backend/app/models.py index dc154f6..b29ed23 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -6,16 +6,20 @@ from datetime import date, datetime, timezone from sqlalchemy import ( Boolean, + Column, Date, DateTime, Enum, Float, ForeignKey, + Index, Integer, LargeBinary, String, + Table, Text, UniqueConstraint, + text, ) from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -119,6 +123,27 @@ class User(Base): created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) +# Ober-/Untergruppen: eine Gruppe darf zu MEHREREN Obergruppen gehoeren, nicht +# nur zu einer. „Grillwurst" haengt unter „Wurst" UND unter „Grillgut" – mit +# einem Baum (ein Elternteil je Gruppe) waere genau das nicht abbildbar. Der +# Graph darf deshalb Rauten haben, aber keine Ringe (Pruefung in routers/groups). +group_parents = Table( + "group_parents", + Base.metadata, + Column( + "child_id", ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True + ), + # Eigener Index: der zusammengesetzte Primaerschluessel deckt nur die Suche + # nach child_id ab, gefragt wird aber genauso oft „wer haengt unter X?". + Column( + "parent_id", + ForeignKey("groups.id", ondelete="CASCADE"), + primary_key=True, + index=True, + ), +) + + class Group(Base): __tablename__ = "groups" @@ -147,6 +172,28 @@ class Group(Base): cascade="all, delete-orphan" ) + # Obergruppen dieser Gruppe (mehrere moeglich) bzw. die Gruppen, die + # umgekehrt unter dieser haengen. Bestand und Mindestbestand einer Gruppe + # zaehlen immer den ganzen Untergraphen mit – siehe services/groups.py. + # ``lazy="selectin"``: /groups laedt alle Gruppen auf einmal – so faellt je + # Ebene EINE Nachladeabfrage an statt einer je Gruppe. + parents: Mapped[list["Group"]] = relationship( + "Group", + secondary=group_parents, + primaryjoin=lambda: Group.id == group_parents.c.child_id, + secondaryjoin=lambda: Group.id == group_parents.c.parent_id, + back_populates="children", + lazy="selectin", + ) + children: Mapped[list["Group"]] = relationship( + "Group", + secondary=group_parents, + primaryjoin=lambda: Group.id == group_parents.c.parent_id, + secondaryjoin=lambda: Group.id == group_parents.c.child_id, + back_populates="parents", + lazy="selectin", + ) + # Lagerorte tragen einen zufälligen 10-Zeichen-Code als ID statt einer # fortlaufenden Zahl. So kollidieren Sicherung/Import zwischen zwei Instanzen @@ -607,10 +654,18 @@ class ItemDocument(Base): class ProductLocationMinStock(Base): - """Mindestbestand eines Produkts an EINEM Lagerort (in Artikeleinheiten). + """Mindestbestand eines Produkts an EINEM Lagerort (in Basiseinheiten). - Zusätzlich zum globalen ``Product.min_stock``: so laesst sich derselbe Artikel - an mehreren Orten getrennt fuehren (z.B. 5 zuhause, 3 im Ferienhaus). + Der einzige Ort, an dem Artikel-Mindestbestaende stehen. ``location_id`` + NULL heisst „Ueberall" – egal wo, Hauptsache die Menge ist im Haus; das war + frueher das separate Feld ``Product.min_stock``. Damit ist „Ueberall" die + Wurzel ueber allen Lagerorten und wird von derselben Verrechnung erfasst wie + verschachtelte Orte (siehe routers/views.py). + + Mengen stehen in BASISEINHEITEN (g/ml/Stueck), nicht in Artikeleinheiten: + sonst bedeutet ein gespeicherter Wert etwas anderes, sobald sich die + Packungsgroesse aendert. In welcher Einheit er angezeigt und eingetippt + wird, sagen ``Product.min_stock_unit_id`` und ``min_stock_in_packages``. """ __tablename__ = "product_location_min_stock" @@ -619,19 +674,35 @@ class ProductLocationMinStock(Base): product_id: Mapped[int] = mapped_column( ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True ) - location_id: Mapped[str] = mapped_column( - String(10), ForeignKey("locations.id", ondelete="CASCADE"), nullable=False + location_id: Mapped[str | None] = mapped_column( + String(10), ForeignKey("locations.id", ondelete="CASCADE"), nullable=True ) - # In Artikeleinheiten (Packungen/Stueck), wie in der Produktliste gezaehlt. min_stock: Mapped[float] = mapped_column(Float, nullable=False) - location: Mapped[Location] = relationship() + location: Mapped[Location | None] = relationship() - __table_args__ = (UniqueConstraint("product_id", "location_id", name="uq_prod_loc_min"),) + __table_args__ = ( + UniqueConstraint("product_id", "location_id", name="uq_prod_loc_min"), + # Postgres zaehlt NULLs in UNIQUE als verschieden – der Constraint oben + # verhindert also KEINE zwei „Ueberall"-Zeilen. Dafuer ein Teilindex. + Index( + "uq_prod_ueberall_min", + "product_id", + unique=True, + postgresql_where=text("location_id IS NULL"), + sqlite_where=text("location_id IS NULL"), + ), + ) class GroupLocationMinStock(Base): - """Mindestbestand einer Gruppe an EINEM Lagerort (in der Gruppen-Einheit).""" + """Mindestbestand einer Gruppe an EINEM Lagerort (in Basiseinheiten). + + ``location_id`` NULL = „Ueberall", genau wie bei + :class:`ProductLocationMinStock`. Auch hier Basiseinheiten – nur so lassen + sich Ober- und Untergruppe gegeneinander verrechnen, wenn die eine in + Kilogramm und die andere in Glaesern erfasst ist. + """ __tablename__ = "group_location_min_stock" @@ -639,11 +710,20 @@ class GroupLocationMinStock(Base): group_id: Mapped[int] = mapped_column( ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True ) - location_id: Mapped[str] = mapped_column( - String(10), ForeignKey("locations.id", ondelete="CASCADE"), nullable=False + location_id: Mapped[str | None] = mapped_column( + String(10), ForeignKey("locations.id", ondelete="CASCADE"), nullable=True ) min_stock: Mapped[float] = mapped_column(Float, nullable=False) - location: Mapped[Location] = relationship() + location: Mapped[Location | None] = relationship() - __table_args__ = (UniqueConstraint("group_id", "location_id", name="uq_group_loc_min"),) + __table_args__ = ( + UniqueConstraint("group_id", "location_id", name="uq_group_loc_min"), + Index( + "uq_group_ueberall_min", + "group_id", + unique=True, + postgresql_where=text("location_id IS NULL"), + sqlite_where=text("location_id IS NULL"), + ), + ) diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index d16c9b4..c5ca48b 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -44,9 +44,10 @@ from ..schemas import ( FlowPoint, TimelinePoint, ) -from ..services.conversion import article_unit, group_min_context +from ..services.conversion import article_unit from ..services.stock import current_stock from .settings import get_expiry_warning_days +from .views import einkaufsliste_artikel, einkaufsliste_gruppen router = APIRouter(prefix="/dashboard", tags=["dashboard"]) @@ -348,16 +349,10 @@ def stats( elif zustand == "soon": bald += 1 - # Einkaufsbedarf: Produkte und Gruppen unter Mindestbestand. - bedarf = 0 - for product in db.query(Product).filter(Product.min_stock.isnot(None), Product.min_stock > 0): - if current_stock(db, product.id) < product.min_stock: - bedarf += 1 - for group in db.query(Group).filter(Group.min_stock.isnot(None), Group.min_stock > 0): - ctx = group_min_context(group) - summe_base = float(sum(current_stock(db, p.id) for p in ctx.matching)) - if summe_base < group.min_stock * ctx.divisor: - bedarf += 1 + # Einkaufsbedarf: so viele Zeilen, wie auf der Einkaufsliste stehen – + # inklusive der Verrechnung zwischen „Überall", Lagerorten und Unter- + # gruppen. Frueher wurde hier eigenstaendig gezaehlt und wich deshalb ab. + bedarf = len(einkaufsliste_artikel(db)) + len(einkaufsliste_gruppen(db)) return DashboardStats( products_in_stock=len(bestand_produkte), diff --git a/backend/app/routers/groups.py b/backend/app/routers/groups.py index a976f49..3802b96 100644 --- a/backend/app/routers/groups.py +++ b/backend/app/routers/groups.py @@ -15,16 +15,50 @@ from ..schemas import ( LocationMinStockOut, ProductBarcodeOut, ) +from ..services import gruppen as gruppen_graph from ..services.conversion import group_min_context -from ..services.stock import current_stock, location_subtree_stock_base +from ..services.min_stock import UEBERALL_NAME, lies_ueberall, schreibe_ueberall +from ..services.stock import summe_bestand_base, summe_bestand_im_subtree_base router = APIRouter(prefix="/groups", tags=["groups"]) +def _obergruppen_setzen(db: Session, group: Group, parent_ids: list[int]) -> None: + """Obergruppen einer Gruppe ersetzen – mit Ringschutz. + + Ohne die Pruefung entstuende ein Ring, und jede Bestands- oder + Bedarfsrechnung liefe im Kreis. Gleiche Absicherung wie beim Umhaengen einer + Kategorie (routers/categories.py), nur ueber eine Nachfahren-MENGE, weil + Gruppen ein Graph und kein Baum sind. + """ + # Die Nachfahren aendern sich durch das Setzen von OBERgruppen nicht – + # deshalb einmal vor der Schleife bestimmen. + verboten = gruppen_graph.nachfahren_ids(group) | {group.id} + gewuenscht: list[Group] = [] + for pid in dict.fromkeys(parent_ids): # Reihenfolge halten, Dubletten raus + if pid in verboten: + raise HTTPException( + status.HTTP_409_CONFLICT, + "Eine Gruppe kann nicht sich selbst oder einer ihrer " + "Untergruppen untergeordnet werden.", + ) + eltern = db.get(Group, pid) + if eltern is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Obergruppe nicht gefunden") + gewuenscht.append(eltern) + group.parents = gewuenscht + + def _group_to_out(db: Session, group: Group) -> GroupOut: out = GroupOut.model_validate(group) + # ``min_stock`` bleibt nach aussen in der Erfassungseinheit der Gruppe, + # gespeichert ist die „Ueberall"-Zeile aber in Basiseinheiten. + ueberall = lies_ueberall(group.location_min_stocks) products = db.query(Product).filter(Product.group_id == group.id).all() - out.product_count = len(products) + out.parent_ids = [p.id for p in group.parents] + out.child_ids = sorted(c.id for c in group.children) + out.direct_product_count = len(products) + out.product_count = len(gruppen_graph.produkte(group)) # Zu welchem Artikel gehoert ein Code? Der Gruppen-Code entsteht beim # Zuordnen automatisch; die Herkunft soll trotzdem sichtbar bleiben. artikel_zu_code: dict[str, Product] = {} @@ -70,8 +104,9 @@ def _group_to_out(db: Session, group: Group) -> GroupOut: # Bestand in derselben Einheit, in der auch der Mindestbestand erfasst ist # (Gruppen-Gebinde ODER verwaltete Einheit) – so ist beides vergleichbar. ctx = group_min_context(group) - stock_base = float(sum(current_stock(db, p.id) for p in ctx.matching)) + stock_base = summe_bestand_base(db, ctx.matching) out.stock = round(stock_base / ctx.divisor, 3) + out.min_stock = None if ueberall is None else round(ueberall / ctx.divisor, 3) unit = group.min_stock_unit if unit is not None: out.min_stock_unit_name = unit.name @@ -79,18 +114,29 @@ def _group_to_out(db: Session, group: Group) -> GroupOut: out.kind = unit.kind.value # Bestand je Lagerort (inkl. Unterorte) in derselben Einheit wie out.stock. - def _loc_stock(loc_id: str) -> float: - total = sum(location_subtree_stock_base(db, p, loc_id) for p in ctx.matching) - return round(total / ctx.divisor, 3) + # Bestand je Lagerort in BASISEINHEITEN – wie ``min_stock`` in denselben + # Zeilen. Nur ``out.stock`` oben rechnet in der Erfassungseinheit. + def _loc_stock(loc_id: str | None) -> float: + # „Ueberall" (Ort NULL) zaehlt den Gesamtbestand – inkl. Chargen, die + # (noch) an keinem Lagerort liegen. + total = ( + stock_base + if loc_id is None + else summe_bestand_im_subtree_base(db, ctx.matching, loc_id) + ) + return round(total, 3) out.location_min_stocks = [ LocationMinStockOut( location_id=e.location_id, - location_name=e.location.name if e.location else None, + location_name=UEBERALL_NAME if e.location_id is None else ( + e.location.name if e.location else None + ), min_stock=e.min_stock, stock=_loc_stock(e.location_id), ) - for e in sorted(group.location_min_stocks, key=lambda x: x.id) + # „Ueberall" zuerst, danach nach Anlagereihenfolge. + for e in sorted(group.location_min_stocks, key=lambda x: (x.location_id is not None, x.id)) ] return out @@ -110,7 +156,11 @@ def set_group_location_min_stock( db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> GroupOut: - """Gruppen-Mindestbestände je Lagerort ersetzen (Menge 0 = Eintrag entfällt).""" + """Gruppen-Mindestbestände je Lagerort ersetzen (Menge 0 = Eintrag entfällt). + + ``location_id = null`` ist „Überall" und damit ein Ort wie jeder andere – + er ersetzt den frueheren Gesamt-Mindestbestand. Mengen in Basiseinheiten. + """ group = db.get(Group, group_id) if group is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden") @@ -118,11 +168,12 @@ def set_group_location_min_stock( db.query(GroupLocationMinStock).filter( GroupLocationMinStock.group_id == group_id ).delete() - gesehen: set[int] = set() + # None (= Überall) ist ein eigener Schluessel in der Dublettenpruefung. + gesehen: set[str | None] = set() for eintrag in payload: if eintrag.min_stock <= 0 or eintrag.location_id in gesehen: continue - if db.get(Location, eintrag.location_id) is None: + if eintrag.location_id is not None and db.get(Location, eintrag.location_id) is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden") gesehen.add(eintrag.location_id) db.add(GroupLocationMinStock( @@ -145,13 +196,21 @@ def create_group( raise HTTPException(status.HTTP_409_CONFLICT, "Gruppe existiert bereits") group = Group( name=payload.name, - min_stock=payload.min_stock, min_stock_unit_id=payload.min_stock_unit_id, package_size=payload.package_size, package_label=payload.package_label, min_stock_in_packages=payload.min_stock_in_packages, ) db.add(group) + # Erst flushen: die Ringpruefung in _obergruppen_setzen braucht group.id. + db.flush() + _obergruppen_setzen(db, group, payload.parent_ids) + # ``min_stock`` kommt in der Erfassungseinheit der Gruppe, gespeichert wird + # in Basiseinheiten – deshalb ueber den Divisor. + if payload.min_stock is not None: + schreibe_ueberall( + db, group, payload.min_stock * (group_min_context(group).divisor or 1.0) + ) db.commit() db.refresh(group) return _group_to_out(db, group) @@ -168,6 +227,12 @@ def update_group( if group is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden") data = payload.model_dump(exclude_unset=True) + # Muss RAUS, bevor unten stumpf jedes Feld per setattr gesetzt wird – + # ``parent_ids`` ist kein Modellattribut, sondern eine Beziehung. + obergruppen = data.pop("parent_ids", None) + # Ebenfalls kein Modellfeld mehr: der Mindestbestand ist die „Ueberall"-Zeile. + min_gesetzt = "min_stock" in data + min_wert = data.pop("min_stock", None) if "name" in data and data["name"]: clash = ( db.query(Group) @@ -177,24 +242,16 @@ def update_group( if clash: raise HTTPException(status.HTTP_409_CONFLICT, "Name bereits vergeben") - # Wird nur die Erfassungseinheit umgestellt (Gebinde <-> verwaltete Einheit), - # ohne dass der Aufrufer neue Zahlen mitschickt, rechnen wir die vorhandenen - # Mindestbestände so um, dass der *physische* Bedarf gleich bleibt. - alt = group_min_context(group) - umschaltung = ( - any(k in data for k in ("min_stock_in_packages", "package_size", "package_label")) - and "min_stock" not in data - ) for field, value in data.items(): setattr(group, field, value) - if umschaltung: - neu = group_min_context(group) - if neu.divisor != alt.divisor and neu.divisor: - faktor = alt.divisor / neu.divisor - if group.min_stock is not None: - group.min_stock = round(group.min_stock * faktor, 3) - for e in group.location_min_stocks: - e.min_stock = round(e.min_stock * faktor, 3) + if obergruppen is not None: + _obergruppen_setzen(db, group, obergruppen) + # Die Erfassungseinheit umzustellen (Gebinde <-> verwaltete Einheit) braucht + # keine Umrechnung mehr: gespeichert wird in Basiseinheiten, der physische + # Bedarf bleibt dadurch von selbst gleich. + if min_gesetzt: + divisor = group_min_context(group).divisor or 1.0 + schreibe_ueberall(db, group, None if min_wert is None else min_wert * divisor) db.commit() db.refresh(group) return _group_to_out(db, group) @@ -315,5 +372,13 @@ def delete_group( group = db.get(Group, group_id) if group is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden") + # Untergruppen bleiben bestehen und ruecken NICHT nach oben: im Graphen + # waere unklar, an welchen der moeglicherweise mehreren Grosseltern sie + # sollten – ein automatisches Umhaengen wuerde stillschweigend neue + # Bestandssummen erzeugen. Sie verlieren nur die Verbindung. Kanten + # ausdruecklich loesen, weil SQLite Fremdschluessel nicht erzwingt. + group.parents.clear() + group.children.clear() + db.flush() db.delete(group) db.commit() diff --git a/backend/app/routers/maintenance.py b/backend/app/routers/maintenance.py index e33a206..353ac30 100644 --- a/backend/app/routers/maintenance.py +++ b/backend/app/routers/maintenance.py @@ -24,6 +24,7 @@ from ..models import ( Barcode, Category, Group, + group_parents, Location, Lot, Movement, @@ -108,6 +109,10 @@ def reset_all( _delete_products(db) # Restliche EAN-Codes (die an Gruppen hängen) mitnehmen. db.query(Barcode).delete(synchronize_session=False) + # Ober-/Untergruppen-Kanten ausdrücklich: ein Core-DELETE räumt die n:m- + # Zeilen nicht mit ab, und SQLite erzwingt keine Fremdschlüssel – sonst + # blieben verwaiste Kanten stehen und hefteten sich an neu vergebene IDs. + db.execute(group_parents.delete()) db.query(Group).delete(synchronize_session=False) db.query(Category).delete(synchronize_session=False) db.query(Location).delete(synchronize_session=False) diff --git a/backend/app/routers/products.py b/backend/app/routers/products.py index 65a5d4f..1790700 100644 --- a/backend/app/routers/products.py +++ b/backend/app/routers/products.py @@ -46,6 +46,7 @@ from .settings import get_receipt_match_threshold from ..services.conversion import ConversionError, resolve_product_unit from ..services.fields import FieldError, apply_field_values from ..services.group_codes import detach as detach_group_code, sync as sync_group_code +from ..services.min_stock import schreibe_ueberall from ..services.stock import removal_stats router = APIRouter(prefix="/products", tags=["products"]) @@ -467,7 +468,6 @@ def create_product( date_precision=payload.date_precision.value, group_id=payload.group_id, category_id=payload.category_id, - min_stock=payload.min_stock, min_stock_unit_id=payload.min_stock_unit_id, min_stock_in_packages=bool(payload.min_stock_in_packages), shop_id=payload.shop_id, @@ -478,6 +478,8 @@ def create_product( ) db.add(product) db.flush() # product.id fuer den Gruppen-Code + # Der Mindestbestand ist kein Artikelfeld mehr, sondern die „Ueberall"-Zeile. + schreibe_ueberall(db, product, payload.min_stock) sync_group_code(db, product) try: apply_field_values(db, product, payload.field_values) @@ -530,6 +532,11 @@ def update_product( product.base_unit, product.display_unit_id = resolve_product_unit(db, unit_id) except ConversionError as exc: raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc + # ``min_stock`` ist kein Artikelfeld mehr, sondern die „Ueberall"-Zeile. + # Der Vertrag nach aussen bleibt aber gleich (Basiseinheiten), damit + # aeltere App-Versionen und das Web-Formular unveraendert weiterlaufen. + ueberall_gesetzt = "min_stock" in data + ueberall_wert = data.pop("min_stock", None) if data.get("min_stock_in_packages") is None: data.pop("min_stock_in_packages", None) # Spalte ist NOT NULL if data.get("date_precision") is None: @@ -538,6 +545,8 @@ def update_product( data["date_precision"] = data["date_precision"].value for field, value in data.items(): setattr(product, field, value) + if ueberall_gesetzt: + schreibe_ueberall(db, product, ueberall_wert) if field_values is not None: try: apply_field_values(db, product, field_values) diff --git a/backend/app/routers/views.py b/backend/app/routers/views.py index 4a12deb..855b375 100644 --- a/backend/app/routers/views.py +++ b/backend/app/routers/views.py @@ -1,7 +1,8 @@ import math from collections import defaultdict +from collections.abc import Iterable from datetime import date, timedelta -from typing import Callable +from typing import Callable, TypeVar from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session @@ -40,15 +41,22 @@ from ..services.conversion import ( display_unit_info, group_min_context, ) +from ..services import gruppen as gruppen_graph from ..services.stock import ( current_stock, descendant_location_ids, location_subtree_stock_base, + summe_bestand_base, + summe_bestand_im_subtree_base, ) from .settings import get_expiry_warning_days router = APIRouter(tags=["views"]) +#: Schluesseltyp der Bedarfs-Verrechnung: ein Lagerort (bzw. None = „Ueberall") +#: oder ein Paar aus Gruppe und Ort. +K = TypeVar("K") + # ---- Bedarf lesbar aufbereiten (Gebinde als Leitangabe) -------------------- _UNIT_SHORT: dict[BaseUnit, str] = { @@ -109,43 +117,187 @@ def _build_need( ) +def _artikel_bedarfe( + db: Session, ort_desc: dict[str, set[str]] +) -> dict[int, tuple[Product, dict, dict, dict]]: + """Je Artikel Mindestbestand, Bestand und verrechneter Bedarf – je Ort. + + Schluessel ``None`` ist „Ueberall" und liegt ueber allen Lagerorten. Alle + Mengen in Basiseinheiten. + """ + nach_artikel: dict[int, list[ProductLocationMinStock]] = defaultdict(list) + for e in db.query(ProductLocationMinStock).all(): + if e.min_stock > 0: + nach_artikel[e.product_id].append(e) + + ergebnis: dict[int, tuple[Product, dict, dict, dict]] = {} + for product_id, rows in nach_artikel.items(): + product = db.get(Product, product_id) + if product is None: + continue + mins = {e.location_id: e.min_stock for e in rows} + bestand = { + loc: ( + current_stock(db, product.id) + if loc is None + else location_subtree_stock_base(db, product, loc) + ) + for loc in mins + } + needs = _netted_topups(mins, _ort_nachfahren(mins, ort_desc), bestand.__getitem__) + ergebnis[product_id] = (product, mins, bestand, needs) + return ergebnis + + +def einkaufsliste_artikel(db: Session) -> list[ShoppingItem]: + """Artikel, bei denen „Überall" etwas fehlt – egal wo im Haus. + + Käufe für einzelne Lagerorte sind bereits abgezogen: derselbe Artikel steht + dadurch nicht mehr doppelt in der Liste (einmal „Gesamt", einmal je Ort). + """ + plural = _package_plural(db) + ort_desc = _ort_nachfahren_tabelle(db) + items: list[ShoppingItem] = [] + for product, mins, bestand, needs in _artikel_bedarfe(db, ort_desc).values(): + if None not in mins: + continue # nur Ort-Bedarfe, kein „Überall" + fehlt = needs.get(None, 0.0) + if fehlt <= 1e-9: + continue + factor, singular = article_unit(product) + items.append( + ShoppingItem( + product_id=product.id, + name=product.name, + base_unit=product.base_unit, + package_size=product.package_size, + stock=round(bestand[None], 3), + min_stock=mins[None], + deficit=round(fehlt, 3), + need=_build_need( + deficit_base=fehlt, + stock_base=bestand[None], + min_base=mins[None], + factor=factor, + singular=singular, + is_package=bool(product.package_size and product.package_size > 0), + base_unit=product.base_unit, + plural=plural, + ), + ) + ) + items.sort(key=lambda i: i.deficit, reverse=True) + return items + + @router.get("/shopping-list", response_model=list[ShoppingItem]) def shopping_list( db: Session = Depends(get_db), _: User = Depends(get_current_user) ) -> list[ShoppingItem]: - """Produkte, deren Bestand unter dem Mindestbestand liegt.""" + return einkaufsliste_artikel(db) + + +def _gruppen_bedarfe(db: Session, ort_desc: dict[str, set[str]]) -> tuple[dict, dict, dict, dict, dict]: + """Je (Gruppe, Ort) Mindestbestand, Bestand und verrechneter Bedarf. + + Hier wirken ZWEI Hierarchien zusammen: der Gruppen-Graph und der + Lagerort-Baum. „1 kg Grillwurst in die Küche" deckt auch „Wurst in Lemgo". + Deshalb ist ein Schlüssel ein Paar, und ``(h, m)`` gilt als Nachfahre von + ``(g, l)``, wenn h unter g und m unter l liegt (oder gleich ist). + + Verrechnet wird nur, wo die zählenden Artikel der Untergruppe eine Teilmenge + der Obergruppe sind: zählt „Wurst" in Kilogramm, ihre Untergruppe aber in + Stück, kommt ein Kauf dort oben gar nicht an (siehe die Einheiten-Filterung + in ``group_min_context``). + + Alle Mengen in Basiseinheiten – nur darin lassen sich Gruppen mit + verschiedenen Erfassungseinheiten überhaupt gegeneinander verrechnen. + """ + eintraege = [e for e in db.query(GroupLocationMinStock).all() if e.min_stock > 0] + gruppen: dict[int, Group] = {} + for e in eintraege: + if e.group_id not in gruppen: + g = db.get(Group, e.group_id) + if g is not None: + gruppen[e.group_id] = g + + ctxs = {gid: group_min_context(g) for gid, g in gruppen.items()} + artikel = {gid: {p.id for p in c.matching} for gid, c in ctxs.items()} + gruppen_desc = { + gid: { + h.id + for h in gruppen_graph.teilgraph(g) + if h.id != gid and h.id in artikel and artikel[h.id] <= artikel[gid] + } + for gid, g in gruppen.items() + } + + minima: dict[tuple[int, str | None], float] = {} + bestand: dict[tuple[int, str | None], float] = {} + for e in eintraege: + ctx = ctxs.get(e.group_id) + if ctx is None: + continue + schluessel = (e.group_id, e.location_id) + minima[schluessel] = e.min_stock + bestand[schluessel] = ( + summe_bestand_base(db, ctx.matching) + if e.location_id is None + else summe_bestand_im_subtree_base(db, ctx.matching, e.location_id) + ) + + alle_orte = {loc for (_, loc) in minima if loc is not None} + + def nachfahren(k: tuple[int, str | None]) -> set[tuple[int, str | None]]: + gid, loc = k + unten = gruppen_desc.get(gid, set()) | {gid} + # „Überall" (None) liegt über allen Lagerorten. + orte = (alle_orte if loc is None else ort_desc.get(loc, set())) | {loc} + return {(h, m) for h in unten for m in orte if (h, m) in minima} - {k} + + needs = _netted_topups(minima, nachfahren, bestand.__getitem__) + return gruppen, ctxs, minima, bestand, needs + + +def einkaufsliste_gruppen(db: Session) -> list[GroupShoppingItem]: + """Gruppen, bei denen „Überall" etwas fehlt. + + Gruppen-Bestand = Summe der Artikelbestände der Gruppe UND ihrer + Untergruppen (Basiseinheiten). Käufe für Untergruppen und für einzelne + Lagerorte sind bereits abgezogen. + """ plural = _package_plural(db) - items: list[ShoppingItem] = [] - products = ( - db.query(Product) - .filter(Product.min_stock.isnot(None), Product.min_stock > 0) - .all() - ) - for product in products: - stock = current_stock(db, product.id) - if stock < product.min_stock: - factor, singular = article_unit(product) - items.append( - ShoppingItem( - product_id=product.id, - name=product.name, - base_unit=product.base_unit, - package_size=product.package_size, - stock=stock, - min_stock=product.min_stock, - deficit=product.min_stock - stock, - need=_build_need( - deficit_base=product.min_stock - stock, - stock_base=stock, - min_base=product.min_stock, - factor=factor, - singular=singular, - is_package=bool(product.package_size and product.package_size > 0), - base_unit=product.base_unit, - plural=plural, - ), - ) + ort_desc = _ort_nachfahren_tabelle(db) + gruppen, ctxs, minima, bestand, needs = _gruppen_bedarfe(db, ort_desc) + + items: list[GroupShoppingItem] = [] + for (gid, loc), fehlt in needs.items(): + if loc is not None or fehlt <= 1e-9: + continue + ctx = ctxs[gid] + schluessel = (gid, None) + items.append( + GroupShoppingItem( + group_id=gid, + name=gruppen[gid].name, + stock=round(bestand[schluessel] / ctx.divisor, 3), + min_stock=round(minima[schluessel] / ctx.divisor, 3), + deficit=round(fehlt / ctx.divisor, 3), + unit_name=ctx.label, + product_count=len(ctx.matching), + subgroup_count=len(gruppen_graph.nachfahren_ids(gruppen[gid])), + need=_build_need( + deficit_base=fehlt, + stock_base=bestand[schluessel], + min_base=minima[schluessel], + factor=ctx.divisor, + singular=ctx.label, + is_package=ctx.is_package, + base_unit=ctx.base_unit, + plural=plural, + ), ) + ) items.sort(key=lambda i: i.deficit, reverse=True) return items @@ -154,146 +306,136 @@ def shopping_list( def group_shopping_list( db: Session = Depends(get_db), _: User = Depends(get_current_user) ) -> list[GroupShoppingItem]: - """Gruppen, deren Gesamtbestand unter dem Gruppen-Mindestbestand liegt. - - Gruppen-Bestand = Summe der Produktbestände in der Gruppe (in Basiseinheiten). - Sinnvoll, wenn die Produkte einer Gruppe dieselbe Basiseinheit teilen. - """ - plural = _package_plural(db) - items: list[GroupShoppingItem] = [] - groups = ( - db.query(Group).filter(Group.min_stock.isnot(None), Group.min_stock > 0).all() - ) - for group in groups: - ctx = group_min_context(group) - stock_base = float(sum(current_stock(db, p.id) for p in ctx.matching)) - min_base = group.min_stock * ctx.divisor - if stock_base < min_base: - deficit_base = min_base - stock_base - items.append( - GroupShoppingItem( - group_id=group.id, - name=group.name, - stock=round(stock_base / ctx.divisor, 3), - min_stock=group.min_stock, - deficit=round(deficit_base / ctx.divisor, 3), - unit_name=ctx.label, - product_count=len(ctx.matching), - need=_build_need( - deficit_base=deficit_base, - stock_base=stock_base, - min_base=min_base, - factor=ctx.divisor, - singular=ctx.label, - is_package=ctx.is_package, - base_unit=ctx.base_unit, - plural=plural, - ), - ) - ) - items.sort(key=lambda i: i.deficit, reverse=True) - return items + return einkaufsliste_gruppen(db) def _netted_topups( - db: Session, locs_min: dict[str, float], stock_of: Callable[[str], float] -) -> dict[str, float]: - """Bedarf je Ort mit verschachtelten Orten verrechnet: Was in einen Unterort - gekauft wird, liegt auch im Subtree des Oberorts und deckt dessen Bedarf mit. - ``topup(ort)`` ist die je Ort ZUSÄTZLICH nötige Menge – über die Käufe in den - Unterorten hinaus. So kostet „Lemgo braucht 5, Küche braucht 2" bei je 1 fehlend - nur 1 (in die Küche), nicht 2.""" - locs = list(locs_min) - # Nachkommen-Bedarfsorte je Ort (im Lagerort-Baum), memoisiert von unten nach oben. - desc = { - loc: [d for d in locs if d != loc and d in descendant_location_ids(db, loc)] - for loc in locs - } - memo: dict[str, float] = {} + minima: dict[K, float], + nachfahren: Callable[[K], set[K]], + stock_of: Callable[[K], float], +) -> dict[K, float]: + """Bedarfe entlang ihrer Hierarchie verrechnen. - def topup(loc: str) -> float: - if loc not in memo: - committed = sum(topup(d) for d in desc[loc]) - memo[loc] = max(0.0, locs_min[loc] - (stock_of(loc) + committed)) - return memo[loc] + Was fuer einen Nachfahren gekauft wird, liegt auch bei dessen Vorfahren und + deckt deren Bedarf mit. ``topup(x)`` ist die ZUSAETZLICH noetige Menge – + ueber die Kaeufe fuer die Nachfahren hinaus. So kostet „Lemgo braucht 5, + Kueche braucht 2" bei je 1 fehlend nur 1 (in die Kueche), nicht 2. - return {loc: topup(loc) for loc in locs} + ``nachfahren`` liefert die TRANSITIVE Nachfahren-MENGE, nicht die direkten + Kinder. Beim Gruppen-Graphen ist eine Untergruppe ueber mehrere Wege + erreichbar – als Menge zaehlt sie in ``committed`` trotzdem nur einmal. + + Alle Mengen muessen in DERSELBEN Einheit vorliegen (hier: Basiseinheiten), + sonst wird Aepfel mit Birnen verrechnet. + """ + schluessel = list(minima) + desc = {k: [d for d in schluessel if d != k and d in nachfahren(k)] for k in schluessel} + memo: dict[K, float] = {} + + def topup(k: K) -> float: + if k not in memo: + # Vorbelegen schuetzt vor einem Ring in den Daten: der wuerde sonst + # endlos rekursieren (die API laesst keinen zu, ein Import schon). + memo[k] = 0.0 + committed = sum(topup(d) for d in desc[k]) + memo[k] = max(0.0, minima[k] - (stock_of(k) + committed)) + return memo[k] + + return {k: topup(k) for k in schluessel} + + +def _ort_nachfahren( + schluessel: Iterable[str | None], ort_desc: dict[str, set[str]] +) -> Callable[[str | None], set[str | None]]: + """Nachfahren-Funktion fuer die Ort-Hierarchie eines Bedarfssatzes. + + „Ueberall" (``None``) liegt ueber ALLEN Lagerorten. Dadurch verrechnet + dieselbe Faltung auch Ueberall gegen die einzelnen Orte – der frueher + getrennte Gesamt-Mindestbestand erzeugt keinen zweiten Eintrag mehr. + """ + orte = list(schluessel) + + def nachfahren(k: str | None) -> set[str | None]: + if k is None: + return {d for d in orte if d is not None} + return {d for d in orte if d is not None and d in ort_desc.get(k, set())} + + return nachfahren + + +def _ort_nachfahren_tabelle(db: Session) -> dict[str, set[str]]: + """Unterorte je Lagerort, einmal je Anfrage statt je Artikel und Gruppe. + + ``descendant_location_ids`` geht je Ebene an die Datenbank; frueher wurde es + fuer jeden Bedarfssatz neu aufgerufen. + """ + return {loc.id: descendant_location_ids(db, loc.id) for loc in db.query(Location).all()} @router.get("/shopping-list/by-location", response_model=list[LocationNeeds]) def shopping_list_by_location( db: Session = Depends(get_db), _: User = Depends(get_current_user) ) -> list[LocationNeeds]: - """Bedarfe je Lagerort: Produkte und Gruppen, deren Bestand AN DIESEM ORT - unter dem dort hinterlegten Mindestbestand liegt.""" + """Bedarfe je Lagerort: Artikel und Gruppen, denen AN DIESEM ORT etwas fehlt. + + „Überall" gehört nicht hierher – das liefern ``/shopping-list`` und + ``/shopping-list/groups``. Verrechnet wird über beide Listen hinweg, ein + Artikel steht also nur einmal drin. + """ plural = _package_plural(db) + ort_desc = _ort_nachfahren_tabelle(db) prod_needs: dict[str, list[LocationNeedProduct]] = defaultdict(list) group_needs: dict[str, list[LocationNeedGroup]] = defaultdict(list) - # Je Produkt alle Ort-Mindestbestände sammeln und hierarchisch verrechnen. - prod_by_id: dict[int, list[ProductLocationMinStock]] = defaultdict(list) - for e in db.query(ProductLocationMinStock).all(): - prod_by_id[e.product_id].append(e) - for product_id, entries in prod_by_id.items(): - product = db.get(Product, product_id) - if product is None: - continue + for product, mins, bestand, needs in _artikel_bedarfe(db, ort_desc).values(): faktor, label = article_unit(product) faktor = faktor or 1.0 ist_gebinde = bool(product.package_size and product.package_size > 0) - locs_min = {e.location_id: e.min_stock for e in entries} - bestand = {loc: location_subtree_stock_base(db, product, loc) / faktor for loc in locs_min} - for loc, need in _netted_topups(db, locs_min, bestand.__getitem__).items(): - if need > 1e-9: - prod_needs[loc].append(LocationNeedProduct( - product_id=product.id, name=product.name, unit_label=label, - stock=round(bestand[loc], 3), min_stock=locs_min[loc], - deficit=round(need, 3), - # Mengen liegen hier in Artikeleinheiten -> * faktor = Basiseinheiten. - need=_build_need( - deficit_base=need * faktor, - stock_base=bestand[loc] * faktor, - min_base=locs_min[loc] * faktor, - factor=faktor, - singular=label, - is_package=ist_gebinde, - base_unit=product.base_unit, - plural=plural, - ), - )) + for loc, need in needs.items(): + if loc is None or need <= 1e-9: + continue + prod_needs[loc].append(LocationNeedProduct( + product_id=product.id, name=product.name, unit_label=label, + # Nach aussen weiterhin in Artikeleinheiten – gespeichert und + # gerechnet wird intern in Basiseinheiten. + stock=round(bestand[loc] / faktor, 3), + min_stock=round(mins[loc] / faktor, 3), + deficit=round(need / faktor, 3), + need=_build_need( + deficit_base=need, + stock_base=bestand[loc], + min_base=mins[loc], + factor=faktor, + singular=label, + is_package=ist_gebinde, + base_unit=product.base_unit, + plural=plural, + ), + )) - # Je Gruppe genauso – Bestand je Ort ist die Summe der passenden Produkte im Subtree. - group_by_id: dict[int, list[GroupLocationMinStock]] = defaultdict(list) - for e in db.query(GroupLocationMinStock).all(): - group_by_id[e.group_id].append(e) - for group_id, entries in group_by_id.items(): - group = db.get(Group, group_id) - if group is None: + gruppen, ctxs, minima, bestand_g, needs_g = _gruppen_bedarfe(db, ort_desc) + for (gid, loc), need in needs_g.items(): + if loc is None or need <= 1e-9: continue - ctx = group_min_context(group) - # Mengen in der Mindestbestand-Einheit (Gebinde ODER verwaltete Einheit). - locs_min = {e.location_id: e.min_stock for e in entries} - bestand = { - loc: sum(location_subtree_stock_base(db, p, loc) for p in ctx.matching) / ctx.divisor - for loc in locs_min - } - for loc, need in _netted_topups(db, locs_min, bestand.__getitem__).items(): - if need > 1e-9: - group_needs[loc].append(LocationNeedGroup( - group_id=group.id, name=group.name, unit_name=ctx.label, - stock=round(bestand[loc], 3), min_stock=locs_min[loc], - deficit=round(need, 3), - need=_build_need( - deficit_base=need * ctx.divisor, - stock_base=bestand[loc] * ctx.divisor, - min_base=locs_min[loc] * ctx.divisor, - factor=ctx.divisor, - singular=ctx.label, - is_package=ctx.is_package, - base_unit=ctx.base_unit, - plural=plural, - ), - )) + ctx = ctxs[gid] + schluessel = (gid, loc) + group_needs[loc].append(LocationNeedGroup( + group_id=gid, name=gruppen[gid].name, unit_name=ctx.label, + stock=round(bestand_g[schluessel] / ctx.divisor, 3), + min_stock=round(minima[schluessel] / ctx.divisor, 3), + deficit=round(need / ctx.divisor, 3), + subgroup_count=len(gruppen_graph.nachfahren_ids(gruppen[gid])), + need=_build_need( + deficit_base=need, + stock_base=bestand_g[schluessel], + min_base=minima[schluessel], + factor=ctx.divisor, + singular=ctx.label, + is_package=ctx.is_package, + base_unit=ctx.base_unit, + plural=plural, + ), + )) loc_ids = set(prod_needs) | set(group_needs) namen = { diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 8ec7eaf..a3d1fa0 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -110,20 +110,22 @@ class UserUpdate(BaseModel): # ---- Groups ---- class LocationMinStockIn(BaseModel): - """Ein Mindestbestand-Eintrag je Lagerort (Menge in Artikeleinheiten).""" - location_id: str + """Ein Mindestbestand-Eintrag je Lagerort (Menge in Basiseinheiten). + + ``location_id = null`` heisst „Überall" – egal wo, Hauptsache die Menge ist + im Haus. Das ersetzt den frueheren separaten Gesamt-Mindestbestand. + """ + location_id: str | None = None min_stock: float = Field(ge=0) class LocationMinStockOut(BaseModel): model_config = ConfigDict(from_attributes=True) - location_id: str - location_name: str | None = None + location_id: str | None = None + location_name: str | None = None # bei location_id = null: „Überall" min_stock: float - # Bestand AN DIESEM Ort (inkl. Unterorte): bei Produkten in Basiseinheiten - # (wie ``ProductOut.stock``), bei Gruppen in der Gruppen-Einheit (wie - # ``GroupOut.stock``). So kann die Oberfläche auch je Lagerort einen Bestand - # zeigen, nicht nur „Gesamt". + # Bestand AN DIESEM Ort (inkl. Unterorte), ebenfalls in Basiseinheiten – + # bei Artikeln wie bei Gruppen. Bei „Überall" der Gesamtbestand. stock: float | None = None @@ -137,8 +139,17 @@ class GroupOut(BaseModel): package_size: float | None = None package_label: str | None = None min_stock_in_packages: bool = False + # Obergruppen (n:m – eine Gruppe darf unter mehreren haengen) und die + # direkten Untergruppen. Beide als IDs; ``_group_to_out`` setzt sie, weil + # ``Group.parents``/``children`` Objekte sind. + parent_ids: list[int] = [] + child_ids: list[int] = [] # angereichert: + # Artikel im gesamten Untergruppen-Graphen – passt zu ``stock``, das + # ebenfalls transitiv rechnet. ``direct_product_count`` sind die, die + # unmittelbar an dieser Gruppe haengen. product_count: int = 0 + direct_product_count: int = 0 stock: float = 0.0 # Bestand in der aktuellen Erfassungseinheit min_stock_unit_name: str | None = None # Name der verwalteten Einheit (g/kg …) min_stock_unit_factor: float | None = None # Basiseinheiten je verwalteter Einheit @@ -157,6 +168,7 @@ class GroupCreate(BaseModel): package_size: float | None = Field(default=None, ge=0) package_label: str | None = Field(default=None, max_length=32) min_stock_in_packages: bool = False + parent_ids: list[int] = [] class GroupUpdate(BaseModel): @@ -166,6 +178,8 @@ class GroupUpdate(BaseModel): package_size: float | None = Field(default=None, ge=0) package_label: str | None = Field(default=None, max_length=32) min_stock_in_packages: bool | None = None + # Nicht mitgeschickt = unveraendert, [] = alle Obergruppen entfernen. + parent_ids: list[int] | None = None # ---- Categories ---- @@ -728,6 +742,8 @@ class GroupShoppingItem(BaseModel): deficit: float unit_name: str = "" product_count: int + # Wie viele Untergruppen mitgezaehlt werden (0 = keine). + subgroup_count: int = 0 need: ShoppingNeed | None = None @@ -749,6 +765,8 @@ class LocationNeedGroup(BaseModel): stock: float min_stock: float deficit: float + # Wie viele Untergruppen mitgezaehlt werden (0 = keine). + subgroup_count: int = 0 need: ShoppingNeed | None = None diff --git a/backend/app/services/conversion.py b/backend/app/services/conversion.py index f59327b..9a32f03 100644 --- a/backend/app/services/conversion.py +++ b/backend/app/services/conversion.py @@ -13,6 +13,7 @@ from sqlalchemy import func from sqlalchemy.orm import Session from ..models import BaseUnit, Group, Product, Unit, UnitKind +from . import gruppen BASE_OF_KIND: dict[UnitKind, BaseUnit] = { UnitKind.count: BaseUnit.piece, @@ -127,14 +128,21 @@ def group_min_context(group: Group) -> GroupMinContext: ein Gruppen-Gebinde definiert und aktiv ist – in ganzen Packungen. Die Packungsgroesse ist ein **Richtwert der Gruppe** (nicht die der einzelnen Produkte), weil deren Packungen unterschiedlich gross sein koennen. + + ``matching`` umfasst die Artikel der Gruppe UND ihrer Untergruppen + (transitiv): „Wurst" zaehlt Wurst, Grillwurst, Salami und alles darunter. + Die Einheiten-Filterung der OBERgruppe gilt dabei fuer den ganzen + Teilgraphen – zaehlt „Wurst" in Kilogramm, bleiben stueckweise gefuehrte + Artikel einer Untergruppe aussen vor, genau wie ein direkt zugeordneter. """ + alle = gruppen.produkte(group) # inkl. Untergruppen, ohne Dubletten unit = group.min_stock_unit if unit is not None: base = BASE_OF_KIND[unit.kind] - matching = [p for p in group.products if p.base_unit == base] + matching = [p for p in alle if p.base_unit == base] base_unit: BaseUnit | None = base else: - matching = list(group.products) + matching = alle base_unit = None if group.min_stock_in_packages and group.package_size and group.package_size > 0: return GroupMinContext( diff --git a/backend/app/services/gruppen.py b/backend/app/services/gruppen.py new file mode 100644 index 0000000..a0aac89 --- /dev/null +++ b/backend/app/services/gruppen.py @@ -0,0 +1,71 @@ +"""Der Gruppen-Graph: Ober- und Untergruppen. + +Anders als Kategorien und Lagerorte bilden Gruppen KEINEN Baum: „Grillwurst" +haengt unter „Wurst" UND unter „Grillgut" – mit einem einzelnen ``parent_id`` +waere genau das nicht abbildbar. Die Struktur ist deshalb ein gerichteter +azyklischer Graph. + +Daraus folgt die eine Regel, an der hier alles haengt: Auswertungen laufen ueber +MENGEN, nicht ueber Baum-Walks. Eine ueber zwei Wege erreichbare Untergruppe +wuerde sonst doppelt zaehlen und den Bestand ihrer Obergruppe verfaelschen. + +Jede Funktion schuetzt sich zusaetzlich mit einem ``gesehen``-Set gegen Ringe. +Ueber die API kann keiner entstehen (Pruefung in routers/groups.py), eine +eingespielte Sicherung kann aber einen mitbringen – und ein Ring wuerde jede +Auswertung endlos laufen lassen. Gleiche Vorsichtsmassnahme wie in +routers/categories.py. +""" + +from __future__ import annotations + +from ..models import Group, Product + + +def teilgraph(group: Group) -> list[Group]: + """Die Gruppe samt ALLEN Untergruppen (transitiv), jede genau einmal.""" + gesehen: set[int] = set() + ergebnis: list[Group] = [] + offen = [group] + while offen: + aktuell = offen.pop() + if aktuell.id in gesehen: + continue # ueber zwei Wege erreichbar ODER Ring in den Daten + gesehen.add(aktuell.id) + ergebnis.append(aktuell) + offen.extend(aktuell.children) + return ergebnis + + +def nachfahren_ids(group: Group) -> set[int]: + """IDs aller Untergruppen (transitiv, ohne die Gruppe selbst).""" + return {g.id for g in teilgraph(group)} - {group.id} + + +def vorfahren_ids(group: Group) -> set[int]: + """IDs aller Obergruppen (transitiv, ohne die Gruppe selbst).""" + gesehen: set[int] = set() + offen = list(group.parents) + while offen: + aktuell = offen.pop() + if aktuell.id in gesehen: + continue + gesehen.add(aktuell.id) + offen.extend(aktuell.parents) + return gesehen + + +def produkte(group: Group) -> list[Product]: + """Alle Artikel der Gruppe UND ihrer Untergruppen – jeder genau einmal. + + Ein Artikel haengt an genau einer Gruppe (``Product.group_id``), die + Entdopplung greift also schon auf Gruppenebene. Der Artikel-Check bleibt + trotzdem stehen: er kostet nichts und macht die Absicht sichtbar. + """ + gesehen: set[int] = set() + ergebnis: list[Product] = [] + for g in teilgraph(group): + for p in g.products: + if p.id not in gesehen: + gesehen.add(p.id) + ergebnis.append(p) + return ergebnis diff --git a/backend/app/services/min_stock.py b/backend/app/services/min_stock.py new file mode 100644 index 0000000..a5b8e27 --- /dev/null +++ b/backend/app/services/min_stock.py @@ -0,0 +1,151 @@ +"""Mindestbestaende: „Ueberall" als Ort und die einmalige Datenwanderung. + +Mindestbestaende haengen ausschliesslich an Lagerorten. Der frueher separate +Gesamt-Mindestbestand (``Product.min_stock`` / ``Group.min_stock``) ist jetzt +eine Zeile mit ``location_id = NULL`` – „Ueberall": egal wo, Hauptsache die +Menge ist im Haus. Damit ist Ueberall die Wurzel ueber allen Lagerorten und +wird von derselben Verrechnung erfasst wie verschachtelte Orte. + +Alle Mengen stehen in BASISEINHEITEN. Vorher waren es drei verschiedene +Einheiten nebeneinander (Gesamt in Basiseinheiten, je Ort in Artikeleinheiten, +Gruppen in der Gruppen-Erfassungseinheit) – das machte jede Umrechnung zur +Fehlerquelle und liess gespeicherte Werte ihre Bedeutung aendern, sobald sich +eine Packungsgroesse aenderte. +""" + +from __future__ import annotations + +from collections.abc import Iterable + +from sqlalchemy.orm import Session + +from ..models import ( + Group, + GroupLocationMinStock, + Product, + ProductLocationMinStock, + Setting, +) +from .conversion import article_unit, group_min_context + +#: Anzeigename des Ortes NULL – hier und in der Oberflaeche derselbe Begriff. +UEBERALL_NAME = "Überall" + +#: Merker, dass die Datenwanderung gelaufen ist. Schritt 1 (Umrechnen der +#: vorhandenen Werte) waere sonst nicht wiederholungsfest. +MIGRATION_KEY = "min_stock_basis_v2" + + +def lies_ueberall(eintraege: Iterable) -> float | None: + """Der „Ueberall"-Mindestbestand (Ort NULL) aus den Ort-Eintraegen. + + Ersetzt die frueheren Spalten ``Product.min_stock``/``Group.min_stock``. + Ergebnis in Basiseinheiten; ``None`` = kein Ueberall-Bedarf hinterlegt. + """ + for e in eintraege: + if e.location_id is None: + return e.min_stock + return None + + +def schreibe_ueberall(db: Session, besitzer, wert: float | None) -> None: + """Den „Ueberall"-Mindestbestand setzen oder (bei None/0) entfernen. + + ``besitzer`` ist ein ``Product`` oder eine ``Group`` – beide haben eine + ``location_min_stocks``-Beziehung mit derselben Form. Menge in + Basiseinheiten. + """ + vorhanden = next( + (e for e in besitzer.location_min_stocks if e.location_id is None), None + ) + if wert is None or wert <= 0: + if vorhanden is not None: + besitzer.location_min_stocks.remove(vorhanden) + return + if vorhanden is not None: + vorhanden.min_stock = wert + return + if isinstance(besitzer, Product): + neu = ProductLocationMinStock( + product_id=besitzer.id, location_id=None, min_stock=wert + ) + else: + neu = GroupLocationMinStock( + group_id=besitzer.id, location_id=None, min_stock=wert + ) + besitzer.location_min_stocks.append(neu) + + +def migriere_mindestbestaende(db: Session) -> None: + """Einmalig: Gesamt-Werte zu „Ueberall" machen, alles in Basiseinheiten. + + Laeuft beim Start (siehe main.py) und tut beim zweiten Mal nichts mehr. + """ + if db.get(Setting, MIGRATION_KEY) is not None: + return + + # 1. Bestehende Ort-Zeilen umrechnen: Artikel- bzw. Gruppeneinheit -> + # Basiseinheiten. Der einzige Schritt, der bei doppelter Ausfuehrung + # falsche Werte erzeugen wuerde – daher der Merker unten. + for eintrag in db.query(ProductLocationMinStock).all(): + product = db.get(Product, eintrag.product_id) + if product is None: + continue + faktor, _ = article_unit(product) + eintrag.min_stock = eintrag.min_stock * (faktor or 1.0) + + for eintrag in db.query(GroupLocationMinStock).all(): + group = db.get(Group, eintrag.group_id) + if group is None: + continue + eintrag.min_stock = eintrag.min_stock * (group_min_context(group).divisor or 1.0) + + # 2. Artikel-Gesamtwerte werden zur Ueberall-Zeile. ``Product.min_stock`` + # stand bereits in Basiseinheiten, also keine Umrechnung. + artikel = ( + db.query(Product) + .filter(Product.min_stock.isnot(None), Product.min_stock > 0) + .all() + ) + for product in artikel: + schon_da = ( + db.query(ProductLocationMinStock) + .filter( + ProductLocationMinStock.product_id == product.id, + ProductLocationMinStock.location_id.is_(None), + ) + .first() + ) + if schon_da is None: + db.add( + ProductLocationMinStock( + product_id=product.id, location_id=None, min_stock=product.min_stock + ) + ) + product.min_stock = None + + # 3. Gruppen-Gesamtwerte genauso – die stehen in der Erfassungseinheit der + # Gruppe und muessen deshalb ueber den Divisor. + gruppen = db.query(Group).filter(Group.min_stock.isnot(None), Group.min_stock > 0).all() + for group in gruppen: + divisor = group_min_context(group).divisor or 1.0 + schon_da = ( + db.query(GroupLocationMinStock) + .filter( + GroupLocationMinStock.group_id == group.id, + GroupLocationMinStock.location_id.is_(None), + ) + .first() + ) + if schon_da is None: + db.add( + GroupLocationMinStock( + group_id=group.id, + location_id=None, + min_stock=group.min_stock * divisor, + ) + ) + group.min_stock = None + + db.add(Setting(key=MIGRATION_KEY, value="done")) + db.commit() diff --git a/backend/app/services/stock.py b/backend/app/services/stock.py index 6ff587a..6e9ba0b 100644 --- a/backend/app/services/stock.py +++ b/backend/app/services/stock.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Sequence from datetime import date from sqlalchemy import asc, func @@ -215,6 +216,65 @@ def current_stock(db: Session, product_id: int) -> float: return float(sum(q for (q,) in total)) +def summe_bestand_base(db: Session, produkte: Sequence[Product]) -> float: + """Gesamtbestand mehrerer Artikel (Basiseinheiten) in EINER Abfrage. + + Ersetzt ``sum(current_stock(db, p.id) for p in …)``: das setzte je Artikel + zwei Abfragen ab. Seit eine Gruppe die Artikel ihres ganzen Untergruppen- + Graphen zaehlt, sind das schnell hunderte. + """ + if not produkte: + return 0.0 + # Gleiche Fallunterscheidung wie current_stock: Einzelstuecke zaehlen ihre + # Items, alle anderen summieren die Lot-Mengen. + einzel = [p.id for p in produkte if p.individual] + lots = [p.id for p in produkte if not p.individual] + total = 0.0 + if lots: + total += float( + db.query(func.coalesce(func.sum(Lot.quantity), 0.0)) + .filter(Lot.product_id.in_(lots)) + .scalar() + or 0.0 + ) + if einzel: + total += float( + db.query(func.count(Item.id)).filter(Item.product_id.in_(einzel)).scalar() or 0 + ) + return total + + +def summe_bestand_im_subtree_base( + db: Session, produkte: Sequence[Product], location_id: str +) -> float: + """Wie ``summe_bestand_base``, aber nur an einem Lagerort inkl. Unterorten. + + Chargen ohne Lagerort liegen in keinem Subtree und zaehlen hier bewusst + nicht mit – im Gesamtbestand („Ueberall") dagegen schon. + """ + if not produkte: + return 0.0 + ids = {location_id} | descendant_location_ids(db, location_id) + einzel = [p.id for p in produkte if p.individual] + lots = [p.id for p in produkte if not p.individual] + total = 0.0 + if lots: + total += float( + db.query(func.coalesce(func.sum(Lot.quantity), 0.0)) + .filter(Lot.product_id.in_(lots), Lot.location_id.in_(ids)) + .scalar() + or 0.0 + ) + if einzel: + total += float( + db.query(func.count(Item.id)) + .filter(Item.product_id.in_(einzel), Item.location_id.in_(ids)) + .scalar() + or 0 + ) + return total + + def location_stock_base(db: Session, product: Product, location_id: str) -> float: """Bestand eines Produkts an EINEM Lagerort (in Basiseinheiten). diff --git a/backend/tests/test_gruppen_hierarchie.py b/backend/tests/test_gruppen_hierarchie.py new file mode 100644 index 0000000..c1df3b2 --- /dev/null +++ b/backend/tests/test_gruppen_hierarchie.py @@ -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 diff --git a/backend/tests/test_min_stock_ueberall.py b/backend/tests/test_min_stock_ueberall.py new file mode 100644 index 0000000..383c17b --- /dev/null +++ b/backend/tests/test_min_stock_ueberall.py @@ -0,0 +1,192 @@ +"""Mindestbestände hängen nur noch an Lagerorten – „Überall" ist Ort NULL. + +„Überall" (egal wo, Hauptsache im Haus) ersetzt den früheren Gesamt-Mindest- +bestand und liegt als Wurzel über allen Lagerorten. Dadurch verrechnet dieselbe +Faltung wie bei verschachtelten Orten auch Überall gegen Küche & Co. – ein +Artikel steht nicht mehr doppelt in der Einkaufsliste. +""" + +import pytest +from sqlalchemy.exc import IntegrityError + +from app.models import ( + BaseUnit, + Group, + GroupLocationMinStock, + Location, + Lot, + Product, + ProductLocationMinStock, + Role, + Setting, + Unit, + User, +) +from app.crud import product_to_out +from app.routers.views import shopping_list, shopping_list_by_location +from app.services.min_stock import ( + MIGRATION_KEY, + UEBERALL_NAME, + lies_ueberall, + migriere_mindestbestaende, + 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 _artikel(db, name="Gurken", **kwargs) -> Product: + # package_size=1 -> Artikeleinheit == Basiseinheit, macht die Rechnung klar. + p = Product(name=name, base_unit=BaseUnit.gram, package_size=1, **kwargs) + db.add(p) + db.commit() + db.refresh(p) + return p + + +def test_ueberall_zeile_wird_gelesen(db, user): + p = _artikel(db) + schreibe_ueberall(db, p, 500) + db.commit() + + out = product_to_out(db, p) + assert out.min_stock == pytest.approx(500) + (zeile,) = out.location_min_stocks + assert zeile.location_id is None + assert zeile.location_name == UEBERALL_NAME + assert lies_ueberall(p.location_min_stocks) == pytest.approx(500) + + +def test_ueberall_auf_null_setzen_entfernt_die_zeile(db, user): + p = _artikel(db) + schreibe_ueberall(db, p, 500) + db.commit() + schreibe_ueberall(db, p, None) + db.commit() + + assert p.location_min_stocks == [] + assert product_to_out(db, p).min_stock is None + + +def test_zwei_ueberall_zeilen_werden_abgelehnt(db, user): + """Der Teilindex ersetzt den UNIQUE-Schutz, den NULLs nicht bekommen.""" + p = _artikel(db) + db.add(ProductLocationMinStock(product_id=p.id, location_id=None, min_stock=500)) + db.commit() + db.add(ProductLocationMinStock(product_id=p.id, location_id=None, min_stock=900)) + with pytest.raises(IntegrityError): + db.commit() + db.rollback() + + +def test_ueberall_wird_gegen_lagerort_verrechnet(db, user): + """Überall 5, Küche 2, je 1 vorhanden -> nicht 6 kaufen. + + Für die Küche fehlt 1. Das liegt danach auch im Haus und deckt Überall mit: + dort fehlen 5 - (1 vorhanden + 1 gekauft) = 3. + """ + p = _artikel(db, "Gurken") + kueche = Location(name="Küche") + db.add(kueche) + db.flush() + db.add(Lot(product_id=p.id, quantity=1, location_id=kueche.id)) + db.add_all([ + ProductLocationMinStock(product_id=p.id, location_id=None, min_stock=5), + ProductLocationMinStock(product_id=p.id, location_id=kueche.id, min_stock=2), + ]) + db.commit() + + (gesamt,) = shopping_list(db=db, _=user) + assert gesamt.deficit == pytest.approx(3) + + nach_ort = {n.location_name: n for n in shopping_list_by_location(db=db, _=user)} + assert nach_ort["Küche"].products[0].deficit == pytest.approx(1) + # „Überall" gehört nicht in die Ortsliste. + assert UEBERALL_NAME not in nach_ort + + +def test_ueberall_zaehlt_chargen_ohne_ort_mit(db, user): + """Eine Charge ohne Lagerort liegt in keinem Ort-Subtree, aber im Haus.""" + p = _artikel(db, "Butter") + db.add(Lot(product_id=p.id, quantity=4, location_id=None)) + db.add(ProductLocationMinStock(product_id=p.id, location_id=None, min_stock=5)) + db.commit() + + (gesamt,) = shopping_list(db=db, _=user) + assert gesamt.stock == pytest.approx(4) + assert gesamt.deficit == pytest.approx(1) + + +# ---- Datenwanderung ---- + +def _gramm(db) -> Unit: + return db.query(Unit).filter(Unit.name == "Gramm").one() + + +def test_migration_macht_gesamtwerte_zu_ueberall(db, user): + """Alter Stand: min_stock am Artikel, Ort-Werte in Artikeleinheiten.""" + p = Product(name="Pesto", base_unit=BaseUnit.gram, package_size=150, + package_label="Glas", min_stock=600) + db.add(p) + db.flush() + keller = Location(name="Keller") + db.add(keller) + db.flush() + # 2 Gläser à 150 g – frueher so gespeichert (Artikeleinheiten). + db.add(ProductLocationMinStock(product_id=p.id, location_id=keller.id, min_stock=2)) + db.commit() + + migriere_mindestbestaende(db) + db.refresh(p) + + assert p.min_stock is None # Quelle geleert + werte = {e.location_id: e.min_stock for e in p.location_min_stocks} + assert werte[None] == pytest.approx(600) # war schon Basiseinheiten + assert werte[keller.id] == pytest.approx(300) # 2 Gläser * 150 g + + +def test_migration_rechnet_gruppenwerte_ueber_den_divisor(db, user): + gruppe = Group(name="Pesto", min_stock=3, min_stock_unit=_gramm(db), + package_size=150, package_label="Glas", min_stock_in_packages=True) + db.add(gruppe) + db.flush() + keller = Location(name="Keller") + db.add(keller) + db.flush() + db.add(GroupLocationMinStock(group_id=gruppe.id, location_id=keller.id, min_stock=2)) + db.commit() + + migriere_mindestbestaende(db) + db.refresh(gruppe) + + assert gruppe.min_stock is None + werte = {e.location_id: e.min_stock for e in gruppe.location_min_stocks} + assert werte[None] == pytest.approx(450) # 3 Gläser * 150 g + assert werte[keller.id] == pytest.approx(300) # 2 Gläser * 150 g + + +def test_migration_laeuft_nur_einmal(db, user): + """Der zweite Lauf darf die Werte nicht erneut hochrechnen.""" + p = Product(name="Pesto", base_unit=BaseUnit.gram, package_size=150, min_stock=600) + db.add(p) + db.flush() + keller = Location(name="Keller") + db.add(keller) + db.flush() + db.add(ProductLocationMinStock(product_id=p.id, location_id=keller.id, min_stock=2)) + db.commit() + + migriere_mindestbestaende(db) + migriere_mindestbestaende(db) + db.refresh(p) + + werte = {e.location_id: e.min_stock for e in p.location_min_stocks} + assert werte[keller.id] == pytest.approx(300) # nicht 45000 + assert db.get(Setting, MIGRATION_KEY) is not None diff --git a/backend/tests/test_shopping_need.py b/backend/tests/test_shopping_need.py index e460f07..8bb3b20 100644 --- a/backend/tests/test_shopping_need.py +++ b/backend/tests/test_shopping_need.py @@ -9,9 +9,10 @@ Packungen haben können). import pytest from app.models import BaseUnit, Group, PackageType, Product, Role, Unit, User -from app.routers.groups import update_group +from app.routers.groups import _group_to_out, update_group from app.routers.views import group_shopping_list, shopping_list from app.schemas import GroupUpdate +from app.services.min_stock import schreibe_ueberall @pytest.fixture() @@ -27,12 +28,20 @@ def _gramm(db) -> Unit: return db.query(Unit).filter(Unit.name == "Gramm").one() +def _ueberall(db, besitzer, menge_base: float) -> None: + """Mindestbestand „Überall" setzen – Mengen immer in Basiseinheiten.""" + schreibe_ueberall(db, besitzer, menge_base) + db.commit() + + def test_produkt_bedarf_in_ganzen_gebinden(db, user): """500 g fehlen bei 195-g-Gläsern -> 3 Gläser (aufgerundet), 500 g als Hinweis.""" db.add(PackageType(singular="Glas", plural="Gläser")) - db.add(Product(name="Pesto", base_unit=BaseUnit.gram, package_size=195, - package_label="Glas", min_stock=500)) + pesto = Product(name="Pesto", base_unit=BaseUnit.gram, package_size=195, + package_label="Glas") + db.add(pesto) db.commit() # kein Bestand -> es fehlt der volle Mindestbestand + _ueberall(db, pesto, 500) (item,) = shopping_list(db=db, _=user) assert item.need is not None @@ -45,11 +54,12 @@ def test_produkt_bedarf_in_ganzen_gebinden(db, user): def test_gruppe_mit_gruppen_gebinde(db, user): """Gruppe mit eigenem Richt-Gebinde (1 Glas ≈ 150 g), Mindestbestand 3 Gläser.""" db.add(PackageType(singular="Glas", plural="Gläser")) - gruppe = Group(name="Pesto alla Genovese", min_stock=3, min_stock_unit=_gramm(db), + gruppe = Group(name="Pesto alla Genovese", min_stock_unit=_gramm(db), package_size=150, package_label="Glas", min_stock_in_packages=True) db.add(gruppe) db.add(Product(name="Pesto Barilla", base_unit=BaseUnit.gram, package_size=99, group=gruppe)) db.commit() # kein Bestand -> es fehlen 3 Gläser (450 g) + _ueberall(db, gruppe, 3 * 150) # 3 Gläser à 150 g (item,) = group_shopping_list(db=db, _=user) assert item.need is not None @@ -61,10 +71,11 @@ def test_gruppe_mit_gruppen_gebinde(db, user): def test_gruppe_ohne_gebinde_bleibt_basiseinheit(db, user): """Ohne aktives Gruppen-Gebinde bleibt es bei der verwalteten Einheit.""" - gruppe = Group(name="Mehl", min_stock=500, min_stock_unit=_gramm(db)) + gruppe = Group(name="Mehl", min_stock_unit=_gramm(db)) db.add(gruppe) db.add(Product(name="Mehl 405", base_unit=BaseUnit.gram, group=gruppe)) db.commit() + _ueberall(db, gruppe, 500) (item,) = group_shopping_list(db=db, _=user) assert item.need is not None @@ -74,11 +85,17 @@ def test_gruppe_ohne_gebinde_bleibt_basiseinheit(db, user): def test_gruppe_umschalten_haelt_physischen_bedarf(db, user): - """Umschalten g -> Gebinde rechnet den gespeicherten Wert um (500 g / 150 = 3,33 Glas).""" - gruppe = Group(name="Pesto", min_stock=500, min_stock_unit=_gramm(db)) + """Umschalten g -> Gebinde laesst den physischen Bedarf unveraendert. + + Gespeichert wird in Basiseinheiten, deshalb bleiben die 500 g stehen und es + ist NUR die Anzeige, die auf ~3,33 Glaeser wechselt. Frueher musste der + gespeicherte Wert dafuer umgerechnet werden. + """ + gruppe = Group(name="Pesto", min_stock_unit=_gramm(db)) db.add(gruppe) db.add(Product(name="Pesto Barilla", base_unit=BaseUnit.gram, group=gruppe)) db.commit() + _ueberall(db, gruppe, 500) update_group( group_id=gruppe.id, @@ -87,4 +104,7 @@ def test_gruppe_umschalten_haelt_physischen_bedarf(db, user): ) db.refresh(gruppe) assert gruppe.min_stock_in_packages is True - assert gruppe.min_stock == pytest.approx(500 / 150, abs=0.01) # ~3,33 Gläser + # Gespeichert unveraendert in Basiseinheiten … + assert gruppe.location_min_stocks[0].min_stock == pytest.approx(500) + # … angezeigt jetzt in Glaesern. + assert _group_to_out(db, gruppe).min_stock == pytest.approx(500 / 150, abs=0.01)