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

@@ -12,6 +12,7 @@ from sqlalchemy.orm import Session
from .models import Barcode, Category, CategoryTracking, Item, Lot, Product, ProductImage from .models import Barcode, Category, CategoryTracking, Item, Lot, Product, ProductImage
from .schemas import BarcodeOut, LocationMinStockOut, ProductOut from .schemas import BarcodeOut, LocationMinStockOut, ProductOut
from .services.conversion import KIND_OF_BASE, display_unit_info 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 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_name = name
out.unit_factor = factor out.unit_factor = factor
# Mindestbestand in der Einheit anzeigen, in der er erfasst wurde. # „Ueberall" (Ort NULL) ist der frueher separate Gesamt-Mindestbestand.
if product.min_stock is not None: # ``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: 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" out.min_stock_unit_label = "Pkg"
elif product.min_stock_unit is not None: 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 out.min_stock_unit_label = product.min_stock_unit.name
else: else:
out.min_stock_display = product.min_stock / factor out.min_stock_display = out.min_stock / factor
out.min_stock_unit_label = name out.min_stock_unit_label = name
out.location_min_stocks = [ out.location_min_stocks = [
LocationMinStockOut( LocationMinStockOut(
location_id=e.location_id, 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, 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 return out
@@ -151,24 +164,33 @@ def products_to_out_bulk(db: Session, products: list[Product]) -> list[ProductOu
name, factor = display_unit_info(product) name, factor = display_unit_info(product)
o.unit_name = name o.unit_name = name
o.unit_factor = factor 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: 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" o.min_stock_unit_label = "Pkg"
elif product.min_stock_unit is not None: 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 o.min_stock_unit_label = product.min_stock_unit.name
else: else:
o.min_stock_display = product.min_stock / factor o.min_stock_display = o.min_stock / factor
o.min_stock_unit_label = name o.min_stock_unit_label = name
o.location_min_stocks = [ o.location_min_stocks = [
LocationMinStockOut( LocationMinStockOut(
location_id=e.location_id, 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, 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) out.append(o)
return out return out

View File

@@ -35,6 +35,7 @@ from .seed import (
ensure_first_admin, ensure_first_admin,
) )
from .services.group_codes import backfill as backfill_group_codes from .services.group_codes import backfill as backfill_group_codes
from .services.min_stock import migriere_mindestbestaende
from .services.stock import consolidate_duplicate_lots from .services.stock import consolidate_duplicate_lots
settings = get_settings() settings = get_settings()
@@ -192,6 +193,17 @@ def _ensure_schema() -> None:
# Kaufpreis (in Rappen/Cent) und Währung am Einzelstück. # 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 price_cents INTEGER",
"ALTER TABLE items ADD COLUMN IF NOT EXISTS currency VARCHAR(3)", "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: with engine.begin() as conn:
# Zuerst die Lagerort-ID auf den Code umstellen (einmalig, idempotent), # Zuerst die Lagerort-ID auf den Code umstellen (einmalig, idempotent),
@@ -226,6 +238,9 @@ async def lifespan(app: FastAPI):
ensure_first_admin(db) ensure_first_admin(db)
# Codes bestehender Gruppen-Zuordnungen nachziehen. # Codes bestehender Gruppen-Zuordnungen nachziehen.
backfill_group_codes(db) 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) # Bereits vorhandene Dubletten (gleicher Artikel + MHD + Lagerort)
# einmalig zusammenfassen ab jetzt geschieht das beim Umlagern selbst. # einmalig zusammenfassen ab jetzt geschieht das beim Umlagern selbst.
consolidate_duplicate_lots(db) consolidate_duplicate_lots(db)

View File

@@ -6,16 +6,20 @@ from datetime import date, datetime, timezone
from sqlalchemy import ( from sqlalchemy import (
Boolean, Boolean,
Column,
Date, Date,
DateTime, DateTime,
Enum, Enum,
Float, Float,
ForeignKey, ForeignKey,
Index,
Integer, Integer,
LargeBinary, LargeBinary,
String, String,
Table,
Text, Text,
UniqueConstraint, UniqueConstraint,
text,
) )
from sqlalchemy.orm import Mapped, mapped_column, relationship 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) 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): class Group(Base):
__tablename__ = "groups" __tablename__ = "groups"
@@ -147,6 +172,28 @@ class Group(Base):
cascade="all, delete-orphan" 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 # Lagerorte tragen einen zufälligen 10-Zeichen-Code als ID statt einer
# fortlaufenden Zahl. So kollidieren Sicherung/Import zwischen zwei Instanzen # fortlaufenden Zahl. So kollidieren Sicherung/Import zwischen zwei Instanzen
@@ -607,10 +654,18 @@ class ItemDocument(Base):
class ProductLocationMinStock(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 Der einzige Ort, an dem Artikel-Mindestbestaende stehen. ``location_id``
an mehreren Orten getrennt fuehren (z.B. 5 zuhause, 3 im Ferienhaus). 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" __tablename__ = "product_location_min_stock"
@@ -619,19 +674,35 @@ class ProductLocationMinStock(Base):
product_id: Mapped[int] = mapped_column( product_id: Mapped[int] = mapped_column(
ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True
) )
location_id: Mapped[str] = mapped_column( location_id: Mapped[str | None] = mapped_column(
String(10), ForeignKey("locations.id", ondelete="CASCADE"), nullable=False 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) 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): 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" __tablename__ = "group_location_min_stock"
@@ -639,11 +710,20 @@ class GroupLocationMinStock(Base):
group_id: Mapped[int] = mapped_column( group_id: Mapped[int] = mapped_column(
ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True
) )
location_id: Mapped[str] = mapped_column( location_id: Mapped[str | None] = mapped_column(
String(10), ForeignKey("locations.id", ondelete="CASCADE"), nullable=False String(10), ForeignKey("locations.id", ondelete="CASCADE"), nullable=True
) )
min_stock: Mapped[float] = mapped_column(Float, nullable=False) 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"),
),
)

View File

@@ -44,9 +44,10 @@ from ..schemas import (
FlowPoint, FlowPoint,
TimelinePoint, TimelinePoint,
) )
from ..services.conversion import article_unit, group_min_context from ..services.conversion import article_unit
from ..services.stock import current_stock from ..services.stock import current_stock
from .settings import get_expiry_warning_days from .settings import get_expiry_warning_days
from .views import einkaufsliste_artikel, einkaufsliste_gruppen
router = APIRouter(prefix="/dashboard", tags=["dashboard"]) router = APIRouter(prefix="/dashboard", tags=["dashboard"])
@@ -348,16 +349,10 @@ def stats(
elif zustand == "soon": elif zustand == "soon":
bald += 1 bald += 1
# Einkaufsbedarf: Produkte und Gruppen unter Mindestbestand. # Einkaufsbedarf: so viele Zeilen, wie auf der Einkaufsliste stehen
bedarf = 0 # inklusive der Verrechnung zwischen „Überall", Lagerorten und Unter-
for product in db.query(Product).filter(Product.min_stock.isnot(None), Product.min_stock > 0): # gruppen. Frueher wurde hier eigenstaendig gezaehlt und wich deshalb ab.
if current_stock(db, product.id) < product.min_stock: bedarf = len(einkaufsliste_artikel(db)) + len(einkaufsliste_gruppen(db))
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
return DashboardStats( return DashboardStats(
products_in_stock=len(bestand_produkte), products_in_stock=len(bestand_produkte),

View File

@@ -15,16 +15,50 @@ from ..schemas import (
LocationMinStockOut, LocationMinStockOut,
ProductBarcodeOut, ProductBarcodeOut,
) )
from ..services import gruppen as gruppen_graph
from ..services.conversion import group_min_context 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"]) 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: def _group_to_out(db: Session, group: Group) -> GroupOut:
out = GroupOut.model_validate(group) 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() 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 # Zu welchem Artikel gehoert ein Code? Der Gruppen-Code entsteht beim
# Zuordnen automatisch; die Herkunft soll trotzdem sichtbar bleiben. # Zuordnen automatisch; die Herkunft soll trotzdem sichtbar bleiben.
artikel_zu_code: dict[str, Product] = {} 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 # Bestand in derselben Einheit, in der auch der Mindestbestand erfasst ist
# (Gruppen-Gebinde ODER verwaltete Einheit) so ist beides vergleichbar. # (Gruppen-Gebinde ODER verwaltete Einheit) so ist beides vergleichbar.
ctx = group_min_context(group) 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.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 unit = group.min_stock_unit
if unit is not None: if unit is not None:
out.min_stock_unit_name = unit.name 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 out.kind = unit.kind.value
# Bestand je Lagerort (inkl. Unterorte) in derselben Einheit wie out.stock. # Bestand je Lagerort (inkl. Unterorte) in derselben Einheit wie out.stock.
def _loc_stock(loc_id: str) -> float: # Bestand je Lagerort in BASISEINHEITEN wie ``min_stock`` in denselben
total = sum(location_subtree_stock_base(db, p, loc_id) for p in ctx.matching) # Zeilen. Nur ``out.stock`` oben rechnet in der Erfassungseinheit.
return round(total / ctx.divisor, 3) 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 = [ out.location_min_stocks = [
LocationMinStockOut( LocationMinStockOut(
location_id=e.location_id, 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, min_stock=e.min_stock,
stock=_loc_stock(e.location_id), 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 return out
@@ -110,7 +156,11 @@ def set_group_location_min_stock(
db: Session = Depends(get_db), db: Session = Depends(get_db),
_: User = Depends(require_admin), _: User = Depends(require_admin),
) -> GroupOut: ) -> 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) group = db.get(Group, group_id)
if group is None: if group is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden") raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden")
@@ -118,11 +168,12 @@ def set_group_location_min_stock(
db.query(GroupLocationMinStock).filter( db.query(GroupLocationMinStock).filter(
GroupLocationMinStock.group_id == group_id GroupLocationMinStock.group_id == group_id
).delete() ).delete()
gesehen: set[int] = set() # None (= Überall) ist ein eigener Schluessel in der Dublettenpruefung.
gesehen: set[str | None] = set()
for eintrag in payload: for eintrag in payload:
if eintrag.min_stock <= 0 or eintrag.location_id in gesehen: if eintrag.min_stock <= 0 or eintrag.location_id in gesehen:
continue 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") raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden")
gesehen.add(eintrag.location_id) gesehen.add(eintrag.location_id)
db.add(GroupLocationMinStock( db.add(GroupLocationMinStock(
@@ -145,13 +196,21 @@ def create_group(
raise HTTPException(status.HTTP_409_CONFLICT, "Gruppe existiert bereits") raise HTTPException(status.HTTP_409_CONFLICT, "Gruppe existiert bereits")
group = Group( group = Group(
name=payload.name, name=payload.name,
min_stock=payload.min_stock,
min_stock_unit_id=payload.min_stock_unit_id, min_stock_unit_id=payload.min_stock_unit_id,
package_size=payload.package_size, package_size=payload.package_size,
package_label=payload.package_label, package_label=payload.package_label,
min_stock_in_packages=payload.min_stock_in_packages, min_stock_in_packages=payload.min_stock_in_packages,
) )
db.add(group) 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.commit()
db.refresh(group) db.refresh(group)
return _group_to_out(db, group) return _group_to_out(db, group)
@@ -168,6 +227,12 @@ def update_group(
if group is None: if group is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden") raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden")
data = payload.model_dump(exclude_unset=True) 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"]: if "name" in data and data["name"]:
clash = ( clash = (
db.query(Group) db.query(Group)
@@ -177,24 +242,16 @@ def update_group(
if clash: if clash:
raise HTTPException(status.HTTP_409_CONFLICT, "Name bereits vergeben") 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(): for field, value in data.items():
setattr(group, field, value) setattr(group, field, value)
if umschaltung: if obergruppen is not None:
neu = group_min_context(group) _obergruppen_setzen(db, group, obergruppen)
if neu.divisor != alt.divisor and neu.divisor: # Die Erfassungseinheit umzustellen (Gebinde <-> verwaltete Einheit) braucht
faktor = alt.divisor / neu.divisor # keine Umrechnung mehr: gespeichert wird in Basiseinheiten, der physische
if group.min_stock is not None: # Bedarf bleibt dadurch von selbst gleich.
group.min_stock = round(group.min_stock * faktor, 3) if min_gesetzt:
for e in group.location_min_stocks: divisor = group_min_context(group).divisor or 1.0
e.min_stock = round(e.min_stock * faktor, 3) schreibe_ueberall(db, group, None if min_wert is None else min_wert * divisor)
db.commit() db.commit()
db.refresh(group) db.refresh(group)
return _group_to_out(db, group) return _group_to_out(db, group)
@@ -315,5 +372,13 @@ def delete_group(
group = db.get(Group, group_id) group = db.get(Group, group_id)
if group is None: if group is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden") 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.delete(group)
db.commit() db.commit()

View File

@@ -24,6 +24,7 @@ from ..models import (
Barcode, Barcode,
Category, Category,
Group, Group,
group_parents,
Location, Location,
Lot, Lot,
Movement, Movement,
@@ -108,6 +109,10 @@ def reset_all(
_delete_products(db) _delete_products(db)
# Restliche EAN-Codes (die an Gruppen hängen) mitnehmen. # Restliche EAN-Codes (die an Gruppen hängen) mitnehmen.
db.query(Barcode).delete(synchronize_session=False) 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(Group).delete(synchronize_session=False)
db.query(Category).delete(synchronize_session=False) db.query(Category).delete(synchronize_session=False)
db.query(Location).delete(synchronize_session=False) db.query(Location).delete(synchronize_session=False)

View File

@@ -46,6 +46,7 @@ from .settings import get_receipt_match_threshold
from ..services.conversion import ConversionError, resolve_product_unit from ..services.conversion import ConversionError, resolve_product_unit
from ..services.fields import FieldError, apply_field_values 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.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 from ..services.stock import removal_stats
router = APIRouter(prefix="/products", tags=["products"]) router = APIRouter(prefix="/products", tags=["products"])
@@ -467,7 +468,6 @@ def create_product(
date_precision=payload.date_precision.value, date_precision=payload.date_precision.value,
group_id=payload.group_id, group_id=payload.group_id,
category_id=payload.category_id, category_id=payload.category_id,
min_stock=payload.min_stock,
min_stock_unit_id=payload.min_stock_unit_id, min_stock_unit_id=payload.min_stock_unit_id,
min_stock_in_packages=bool(payload.min_stock_in_packages), min_stock_in_packages=bool(payload.min_stock_in_packages),
shop_id=payload.shop_id, shop_id=payload.shop_id,
@@ -478,6 +478,8 @@ def create_product(
) )
db.add(product) db.add(product)
db.flush() # product.id fuer den Gruppen-Code 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) sync_group_code(db, product)
try: try:
apply_field_values(db, product, payload.field_values) 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) product.base_unit, product.display_unit_id = resolve_product_unit(db, unit_id)
except ConversionError as exc: except ConversionError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from 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: if data.get("min_stock_in_packages") is None:
data.pop("min_stock_in_packages", None) # Spalte ist NOT NULL data.pop("min_stock_in_packages", None) # Spalte ist NOT NULL
if data.get("date_precision") is None: if data.get("date_precision") is None:
@@ -538,6 +545,8 @@ def update_product(
data["date_precision"] = data["date_precision"].value data["date_precision"] = data["date_precision"].value
for field, value in data.items(): for field, value in data.items():
setattr(product, field, value) setattr(product, field, value)
if ueberall_gesetzt:
schreibe_ueberall(db, product, ueberall_wert)
if field_values is not None: if field_values is not None:
try: try:
apply_field_values(db, product, field_values) apply_field_values(db, product, field_values)

View File

@@ -1,7 +1,8 @@
import math import math
from collections import defaultdict from collections import defaultdict
from collections.abc import Iterable
from datetime import date, timedelta from datetime import date, timedelta
from typing import Callable from typing import Callable, TypeVar
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -40,15 +41,22 @@ from ..services.conversion import (
display_unit_info, display_unit_info,
group_min_context, group_min_context,
) )
from ..services import gruppen as gruppen_graph
from ..services.stock import ( from ..services.stock import (
current_stock, current_stock,
descendant_location_ids, descendant_location_ids,
location_subtree_stock_base, location_subtree_stock_base,
summe_bestand_base,
summe_bestand_im_subtree_base,
) )
from .settings import get_expiry_warning_days from .settings import get_expiry_warning_days
router = APIRouter(tags=["views"]) 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) -------------------- # ---- Bedarf lesbar aufbereiten (Gebinde als Leitangabe) --------------------
_UNIT_SHORT: dict[BaseUnit, str] = { _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]) @router.get("/shopping-list", response_model=list[ShoppingItem])
def shopping_list( def shopping_list(
db: Session = Depends(get_db), _: User = Depends(get_current_user) db: Session = Depends(get_db), _: User = Depends(get_current_user)
) -> list[ShoppingItem]: ) -> 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) plural = _package_plural(db)
items: list[ShoppingItem] = [] ort_desc = _ort_nachfahren_tabelle(db)
products = ( gruppen, ctxs, minima, bestand, needs = _gruppen_bedarfe(db, ort_desc)
db.query(Product)
.filter(Product.min_stock.isnot(None), Product.min_stock > 0) items: list[GroupShoppingItem] = []
.all() for (gid, loc), fehlt in needs.items():
) if loc is not None or fehlt <= 1e-9:
for product in products: continue
stock = current_stock(db, product.id) ctx = ctxs[gid]
if stock < product.min_stock: schluessel = (gid, None)
factor, singular = article_unit(product) items.append(
items.append( GroupShoppingItem(
ShoppingItem( group_id=gid,
product_id=product.id, name=gruppen[gid].name,
name=product.name, stock=round(bestand[schluessel] / ctx.divisor, 3),
base_unit=product.base_unit, min_stock=round(minima[schluessel] / ctx.divisor, 3),
package_size=product.package_size, deficit=round(fehlt / ctx.divisor, 3),
stock=stock, unit_name=ctx.label,
min_stock=product.min_stock, product_count=len(ctx.matching),
deficit=product.min_stock - stock, subgroup_count=len(gruppen_graph.nachfahren_ids(gruppen[gid])),
need=_build_need( need=_build_need(
deficit_base=product.min_stock - stock, deficit_base=fehlt,
stock_base=stock, stock_base=bestand[schluessel],
min_base=product.min_stock, min_base=minima[schluessel],
factor=factor, factor=ctx.divisor,
singular=singular, singular=ctx.label,
is_package=bool(product.package_size and product.package_size > 0), is_package=ctx.is_package,
base_unit=product.base_unit, base_unit=ctx.base_unit,
plural=plural, plural=plural,
), ),
)
) )
)
items.sort(key=lambda i: i.deficit, reverse=True) items.sort(key=lambda i: i.deficit, reverse=True)
return items return items
@@ -154,146 +306,136 @@ def shopping_list(
def group_shopping_list( def group_shopping_list(
db: Session = Depends(get_db), _: User = Depends(get_current_user) db: Session = Depends(get_db), _: User = Depends(get_current_user)
) -> list[GroupShoppingItem]: ) -> list[GroupShoppingItem]:
"""Gruppen, deren Gesamtbestand unter dem Gruppen-Mindestbestand liegt. return einkaufsliste_gruppen(db)
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
def _netted_topups( def _netted_topups(
db: Session, locs_min: dict[str, float], stock_of: Callable[[str], float] minima: dict[K, float],
) -> dict[str, float]: nachfahren: Callable[[K], set[K]],
"""Bedarf je Ort mit verschachtelten Orten verrechnet: Was in einen Unterort stock_of: Callable[[K], float],
gekauft wird, liegt auch im Subtree des Oberorts und deckt dessen Bedarf mit. ) -> dict[K, float]:
``topup(ort)`` ist die je Ort ZUSÄTZLICH nötige Menge über die Käufe in den """Bedarfe entlang ihrer Hierarchie verrechnen.
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] = {}
def topup(loc: str) -> float: Was fuer einen Nachfahren gekauft wird, liegt auch bei dessen Vorfahren und
if loc not in memo: deckt deren Bedarf mit. ``topup(x)`` ist die ZUSAETZLICH noetige Menge
committed = sum(topup(d) for d in desc[loc]) ueber die Kaeufe fuer die Nachfahren hinaus. So kostet „Lemgo braucht 5,
memo[loc] = max(0.0, locs_min[loc] - (stock_of(loc) + committed)) Kueche braucht 2" bei je 1 fehlend nur 1 (in die Kueche), nicht 2.
return memo[loc]
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]) @router.get("/shopping-list/by-location", response_model=list[LocationNeeds])
def shopping_list_by_location( def shopping_list_by_location(
db: Session = Depends(get_db), _: User = Depends(get_current_user) db: Session = Depends(get_db), _: User = Depends(get_current_user)
) -> list[LocationNeeds]: ) -> list[LocationNeeds]:
"""Bedarfe je Lagerort: Produkte und Gruppen, deren Bestand AN DIESEM ORT """Bedarfe je Lagerort: Artikel und Gruppen, denen AN DIESEM ORT etwas fehlt.
unter dem dort hinterlegten Mindestbestand liegt."""
„Ü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) plural = _package_plural(db)
ort_desc = _ort_nachfahren_tabelle(db)
prod_needs: dict[str, list[LocationNeedProduct]] = defaultdict(list) prod_needs: dict[str, list[LocationNeedProduct]] = defaultdict(list)
group_needs: dict[str, list[LocationNeedGroup]] = defaultdict(list) group_needs: dict[str, list[LocationNeedGroup]] = defaultdict(list)
# Je Produkt alle Ort-Mindestbestände sammeln und hierarchisch verrechnen. for product, mins, bestand, needs in _artikel_bedarfe(db, ort_desc).values():
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
faktor, label = article_unit(product) faktor, label = article_unit(product)
faktor = faktor or 1.0 faktor = faktor or 1.0
ist_gebinde = bool(product.package_size and product.package_size > 0) ist_gebinde = bool(product.package_size and product.package_size > 0)
locs_min = {e.location_id: e.min_stock for e in entries} for loc, need in needs.items():
bestand = {loc: location_subtree_stock_base(db, product, loc) / faktor for loc in locs_min} if loc is None or need <= 1e-9:
for loc, need in _netted_topups(db, locs_min, bestand.__getitem__).items(): continue
if need > 1e-9: prod_needs[loc].append(LocationNeedProduct(
prod_needs[loc].append(LocationNeedProduct( product_id=product.id, name=product.name, unit_label=label,
product_id=product.id, name=product.name, unit_label=label, # Nach aussen weiterhin in Artikeleinheiten gespeichert und
stock=round(bestand[loc], 3), min_stock=locs_min[loc], # gerechnet wird intern in Basiseinheiten.
deficit=round(need, 3), stock=round(bestand[loc] / faktor, 3),
# Mengen liegen hier in Artikeleinheiten -> * faktor = Basiseinheiten. min_stock=round(mins[loc] / faktor, 3),
need=_build_need( deficit=round(need / faktor, 3),
deficit_base=need * faktor, need=_build_need(
stock_base=bestand[loc] * faktor, deficit_base=need,
min_base=locs_min[loc] * faktor, stock_base=bestand[loc],
factor=faktor, min_base=mins[loc],
singular=label, factor=faktor,
is_package=ist_gebinde, singular=label,
base_unit=product.base_unit, is_package=ist_gebinde,
plural=plural, base_unit=product.base_unit,
), plural=plural,
)) ),
))
# Je Gruppe genauso Bestand je Ort ist die Summe der passenden Produkte im Subtree. gruppen, ctxs, minima, bestand_g, needs_g = _gruppen_bedarfe(db, ort_desc)
group_by_id: dict[int, list[GroupLocationMinStock]] = defaultdict(list) for (gid, loc), need in needs_g.items():
for e in db.query(GroupLocationMinStock).all(): if loc is None or need <= 1e-9:
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:
continue continue
ctx = group_min_context(group) ctx = ctxs[gid]
# Mengen in der Mindestbestand-Einheit (Gebinde ODER verwaltete Einheit). schluessel = (gid, loc)
locs_min = {e.location_id: e.min_stock for e in entries} group_needs[loc].append(LocationNeedGroup(
bestand = { group_id=gid, name=gruppen[gid].name, unit_name=ctx.label,
loc: sum(location_subtree_stock_base(db, p, loc) for p in ctx.matching) / ctx.divisor stock=round(bestand_g[schluessel] / ctx.divisor, 3),
for loc in locs_min min_stock=round(minima[schluessel] / ctx.divisor, 3),
} deficit=round(need / ctx.divisor, 3),
for loc, need in _netted_topups(db, locs_min, bestand.__getitem__).items(): subgroup_count=len(gruppen_graph.nachfahren_ids(gruppen[gid])),
if need > 1e-9: need=_build_need(
group_needs[loc].append(LocationNeedGroup( deficit_base=need,
group_id=group.id, name=group.name, unit_name=ctx.label, stock_base=bestand_g[schluessel],
stock=round(bestand[loc], 3), min_stock=locs_min[loc], min_base=minima[schluessel],
deficit=round(need, 3), factor=ctx.divisor,
need=_build_need( singular=ctx.label,
deficit_base=need * ctx.divisor, is_package=ctx.is_package,
stock_base=bestand[loc] * ctx.divisor, base_unit=ctx.base_unit,
min_base=locs_min[loc] * ctx.divisor, plural=plural,
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) loc_ids = set(prod_needs) | set(group_needs)
namen = { namen = {

View File

@@ -110,20 +110,22 @@ class UserUpdate(BaseModel):
# ---- Groups ---- # ---- Groups ----
class LocationMinStockIn(BaseModel): class LocationMinStockIn(BaseModel):
"""Ein Mindestbestand-Eintrag je Lagerort (Menge in Artikeleinheiten).""" """Ein Mindestbestand-Eintrag je Lagerort (Menge in Basiseinheiten).
location_id: str
``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) min_stock: float = Field(ge=0)
class LocationMinStockOut(BaseModel): class LocationMinStockOut(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
location_id: str location_id: str | None = None
location_name: str | None = None location_name: str | None = None # bei location_id = null: „Überall"
min_stock: float min_stock: float
# Bestand AN DIESEM Ort (inkl. Unterorte): bei Produkten in Basiseinheiten # Bestand AN DIESEM Ort (inkl. Unterorte), ebenfalls in Basiseinheiten
# (wie ``ProductOut.stock``), bei Gruppen in der Gruppen-Einheit (wie # bei Artikeln wie bei Gruppen. Bei „Überall" der Gesamtbestand.
# ``GroupOut.stock``). So kann die Oberfläche auch je Lagerort einen Bestand
# zeigen, nicht nur „Gesamt".
stock: float | None = None stock: float | None = None
@@ -137,8 +139,17 @@ class GroupOut(BaseModel):
package_size: float | None = None package_size: float | None = None
package_label: str | None = None package_label: str | None = None
min_stock_in_packages: bool = False 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: # 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 product_count: int = 0
direct_product_count: int = 0
stock: float = 0.0 # Bestand in der aktuellen Erfassungseinheit 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_name: str | None = None # Name der verwalteten Einheit (g/kg …)
min_stock_unit_factor: float | None = None # Basiseinheiten je verwalteter Einheit 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_size: float | None = Field(default=None, ge=0)
package_label: str | None = Field(default=None, max_length=32) package_label: str | None = Field(default=None, max_length=32)
min_stock_in_packages: bool = False min_stock_in_packages: bool = False
parent_ids: list[int] = []
class GroupUpdate(BaseModel): class GroupUpdate(BaseModel):
@@ -166,6 +178,8 @@ class GroupUpdate(BaseModel):
package_size: float | None = Field(default=None, ge=0) package_size: float | None = Field(default=None, ge=0)
package_label: str | None = Field(default=None, max_length=32) package_label: str | None = Field(default=None, max_length=32)
min_stock_in_packages: bool | None = None min_stock_in_packages: bool | None = None
# Nicht mitgeschickt = unveraendert, [] = alle Obergruppen entfernen.
parent_ids: list[int] | None = None
# ---- Categories ---- # ---- Categories ----
@@ -728,6 +742,8 @@ class GroupShoppingItem(BaseModel):
deficit: float deficit: float
unit_name: str = "" unit_name: str = ""
product_count: int product_count: int
# Wie viele Untergruppen mitgezaehlt werden (0 = keine).
subgroup_count: int = 0
need: ShoppingNeed | None = None need: ShoppingNeed | None = None
@@ -749,6 +765,8 @@ class LocationNeedGroup(BaseModel):
stock: float stock: float
min_stock: float min_stock: float
deficit: float deficit: float
# Wie viele Untergruppen mitgezaehlt werden (0 = keine).
subgroup_count: int = 0
need: ShoppingNeed | None = None need: ShoppingNeed | None = None

View File

@@ -13,6 +13,7 @@ from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..models import BaseUnit, Group, Product, Unit, UnitKind from ..models import BaseUnit, Group, Product, Unit, UnitKind
from . import gruppen
BASE_OF_KIND: dict[UnitKind, BaseUnit] = { BASE_OF_KIND: dict[UnitKind, BaseUnit] = {
UnitKind.count: BaseUnit.piece, 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 ein Gruppen-Gebinde definiert und aktiv ist in ganzen Packungen. Die
Packungsgroesse ist ein **Richtwert der Gruppe** (nicht die der einzelnen Packungsgroesse ist ein **Richtwert der Gruppe** (nicht die der einzelnen
Produkte), weil deren Packungen unterschiedlich gross sein koennen. 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 unit = group.min_stock_unit
if unit is not None: if unit is not None:
base = BASE_OF_KIND[unit.kind] 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 base_unit: BaseUnit | None = base
else: else:
matching = list(group.products) matching = alle
base_unit = None base_unit = None
if group.min_stock_in_packages and group.package_size and group.package_size > 0: if group.min_stock_in_packages and group.package_size and group.package_size > 0:
return GroupMinContext( return GroupMinContext(

View File

@@ -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

View File

@@ -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()

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Sequence
from datetime import date from datetime import date
from sqlalchemy import asc, func 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)) 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: def location_stock_base(db: Session, product: Product, location_id: str) -> float:
"""Bestand eines Produkts an EINEM Lagerort (in Basiseinheiten). """Bestand eines Produkts an EINEM Lagerort (in Basiseinheiten).

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

View File

@@ -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

View File

@@ -9,9 +9,10 @@ Packungen haben können).
import pytest import pytest
from app.models import BaseUnit, Group, PackageType, Product, Role, Unit, User 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.routers.views import group_shopping_list, shopping_list
from app.schemas import GroupUpdate from app.schemas import GroupUpdate
from app.services.min_stock import schreibe_ueberall
@pytest.fixture() @pytest.fixture()
@@ -27,12 +28,20 @@ def _gramm(db) -> Unit:
return db.query(Unit).filter(Unit.name == "Gramm").one() 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): 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.""" """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(PackageType(singular="Glas", plural="Gläser"))
db.add(Product(name="Pesto", base_unit=BaseUnit.gram, package_size=195, pesto = Product(name="Pesto", base_unit=BaseUnit.gram, package_size=195,
package_label="Glas", min_stock=500)) package_label="Glas")
db.add(pesto)
db.commit() # kein Bestand -> es fehlt der volle Mindestbestand db.commit() # kein Bestand -> es fehlt der volle Mindestbestand
_ueberall(db, pesto, 500)
(item,) = shopping_list(db=db, _=user) (item,) = shopping_list(db=db, _=user)
assert item.need is not None 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): def test_gruppe_mit_gruppen_gebinde(db, user):
"""Gruppe mit eigenem Richt-Gebinde (1 Glas ≈ 150 g), Mindestbestand 3 Gläser.""" """Gruppe mit eigenem Richt-Gebinde (1 Glas ≈ 150 g), Mindestbestand 3 Gläser."""
db.add(PackageType(singular="Glas", plural="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) package_size=150, package_label="Glas", min_stock_in_packages=True)
db.add(gruppe) db.add(gruppe)
db.add(Product(name="Pesto Barilla", base_unit=BaseUnit.gram, package_size=99, group=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) 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) (item,) = group_shopping_list(db=db, _=user)
assert item.need is not None 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): def test_gruppe_ohne_gebinde_bleibt_basiseinheit(db, user):
"""Ohne aktives Gruppen-Gebinde bleibt es bei der verwalteten Einheit.""" """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(gruppe)
db.add(Product(name="Mehl 405", base_unit=BaseUnit.gram, group=gruppe)) db.add(Product(name="Mehl 405", base_unit=BaseUnit.gram, group=gruppe))
db.commit() db.commit()
_ueberall(db, gruppe, 500)
(item,) = group_shopping_list(db=db, _=user) (item,) = group_shopping_list(db=db, _=user)
assert item.need is not None 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): def test_gruppe_umschalten_haelt_physischen_bedarf(db, user):
"""Umschalten g -> Gebinde rechnet den gespeicherten Wert um (500 g / 150 = 3,33 Glas).""" """Umschalten g -> Gebinde laesst den physischen Bedarf unveraendert.
gruppe = Group(name="Pesto", min_stock=500, min_stock_unit=_gramm(db))
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(gruppe)
db.add(Product(name="Pesto Barilla", base_unit=BaseUnit.gram, group=gruppe)) db.add(Product(name="Pesto Barilla", base_unit=BaseUnit.gram, group=gruppe))
db.commit() db.commit()
_ueberall(db, gruppe, 500)
update_group( update_group(
group_id=gruppe.id, group_id=gruppe.id,
@@ -87,4 +104,7 @@ def test_gruppe_umschalten_haelt_physischen_bedarf(db, user):
) )
db.refresh(gruppe) db.refresh(gruppe)
assert gruppe.min_stock_in_packages is True 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)