From 4728a839eaa09b77f83f327004ec3baab5ee7fb8 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 29 Jul 2026 12:23:12 +0200 Subject: [PATCH] Backend: Produktliste gebuendelt laden (N+1 weg, deutlich schneller) list_products rief product_to_out je Artikel auf - pro Artikel mehrere Abfragen (Bestand, abgelaufene Chargen, Codes, Bild, Lazy-Relationen). Bei vielen Artikeln = hunderte Queries und mehrere Sekunden. Neu: products_to_out_bulk holt Bestand/Abgelaufen/Codes/Bild je einmal fuer alle und laedt Relationen eager (joinedload/selectinload). Ergebnis identisch (Test), aber nur noch wenige Queries. Co-Authored-By: Claude Opus 4.8 --- backend/app/crud.py | 85 +++++++++++++++++++++++++++++- backend/app/routers/products.py | 16 ++++-- backend/tests/test_gegenstaende.py | 24 ++++++++- 3 files changed, 119 insertions(+), 6 deletions(-) diff --git a/backend/app/crud.py b/backend/app/crud.py index 99f0aaf..d9415b5 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -2,12 +2,14 @@ from __future__ import annotations +from collections import defaultdict from datetime import date from fastapi import HTTPException, status +from sqlalchemy import func from sqlalchemy.orm import Session -from .models import Barcode, Category, CategoryTracking, Lot, Product, ProductImage +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.stock import current_stock @@ -89,6 +91,87 @@ def product_to_out(db: Session, product: Product) -> ProductOut: return out +def products_to_out_bulk(db: Session, products: list[Product]) -> list[ProductOut]: + """Wie ``product_to_out``, aber für viele Artikel mit **gebündelten** Abfragen + statt N+1 – für die Listen-Ansichten. Bestand, abgelaufene Chargen, Codes und + Bild-Version werden je einmal für alle Artikel geholt; Relationen (Kategorie, + Shop, Einheiten, Bedarfe, Feldwerte) sollten vom Aufrufer eager-geladen sein. + """ + if not products: + return [] + ids = [p.id for p in products] + + # Bestand: Lot-Summen (Nicht-Einzelstücke) bzw. Item-Anzahl (Einzelstücke). + lot_sums = dict( + db.query(Lot.product_id, func.coalesce(func.sum(Lot.quantity), 0.0)) + .filter(Lot.product_id.in_(ids)).group_by(Lot.product_id).all() + ) + item_counts = dict( + db.query(Item.product_id, func.count(Item.id)) + .filter(Item.product_id.in_(ids)).group_by(Item.product_id).all() + ) + # Abgelaufene Chargen je Artikel. + heute = date.today() + expired = dict( + db.query(Lot.product_id, func.count(Lot.id)) + .filter( + Lot.product_id.in_(ids), + Lot.best_before.isnot(None), + Lot.best_before < heute, + Lot.quantity > 0, + ) + .group_by(Lot.product_id).all() + ) + # Zusätzliche EAN-Codes je Artikel. + codes: dict[int, list[Barcode]] = defaultdict(list) + for b in db.query(Barcode).filter(Barcode.product_id.in_(ids)).order_by(Barcode.id).all(): + codes[b.product_id].append(b) + # Bild-Version (Epoch der letzten Bildänderung) je Artikel. + imgs = dict( + db.query(ProductImage.product_id, ProductImage.updated_at) + .filter(ProductImage.product_id.in_(ids)).all() + ) + + out: list[ProductOut] = [] + for product in products: + o = ProductOut.model_validate(product) + o.stock = ( + float(item_counts.get(product.id, 0)) if product.individual + else float(lot_sums.get(product.id, 0.0)) + ) + o.expired_count = expired.get(product.id, 0) + o.barcodes = [BarcodeOut.model_validate(b) for b in codes.get(product.id, [])] + o.category_name = product.category.name if product.category else None + ts = imgs.get(product.id) + o.image_version = int(ts.timestamp()) if ts is not None else None + o.tracking = CategoryTracking(product_tracking(db, product)) + o.shop_name = product.shop.name if product.shop else None + o.kind = KIND_OF_BASE[product.base_unit].value + name, factor = display_unit_info(product) + o.unit_name = name + o.unit_factor = factor + if product.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_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_unit_label = product.min_stock_unit.name + else: + o.min_stock_display = product.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, + min_stock=e.min_stock, + ) + for e in sorted(product.location_min_stocks, key=lambda x: x.id) + ] + out.append(o) + return out + + def resolve_product( db: Session, product_id: int | None, barcode: str | None ) -> Product: diff --git a/backend/app/routers/products.py b/backend/app/routers/products.py index 7f73db9..03bb188 100644 --- a/backend/app/routers/products.py +++ b/backend/app/routers/products.py @@ -1,8 +1,8 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status from fastapi.responses import Response -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, joinedload, selectinload -from ..crud import product_to_out, product_tracking +from ..crud import product_to_out, product_tracking, products_to_out_bulk from ..database import get_db from ..deps import get_current_user, require_admin from ..models import ( @@ -56,7 +56,15 @@ def list_products( Kategorie zählen die Unterkategorien mit: Wer auf "Süßwaren" filtert, will auch "Schokolade" sehen. """ - query = db.query(Product) + query = db.query(Product).options( + # Relationen vorab laden, damit products_to_out_bulk kein N+1 auslöst. + joinedload(Product.category), + joinedload(Product.shop), + joinedload(Product.display_unit), + joinedload(Product.min_stock_unit), + selectinload(Product.field_values), + selectinload(Product.location_min_stocks).joinedload(ProductLocationMinStock.location), + ) if q: like = f"%{q}%" query = query.filter(Product.name.ilike(like)) @@ -65,7 +73,7 @@ def list_products( elif category_id is not None: query = query.filter(Product.category_id.in_(descendant_ids(db, category_id))) products = query.order_by(Product.name).all() - return [product_to_out(db, p) for p in products] + return products_to_out_bulk(db, products) @router.get("/lookup", response_model=LookupResult) diff --git a/backend/tests/test_gegenstaende.py b/backend/tests/test_gegenstaende.py index cfaeee7..4fb2a97 100644 --- a/backend/tests/test_gegenstaende.py +++ b/backend/tests/test_gegenstaende.py @@ -9,7 +9,7 @@ import json import pytest -from app.crud import product_tracking +from app.crud import product_to_out, product_tracking, products_to_out_bulk from app.models import ( BaseUnit, Category, @@ -92,6 +92,28 @@ def test_einzelstueck_ohne_kategorie_ist_object(db): assert product_tracking(db, product) == "object" +# ---- Gebündelte Listen-Ausgabe ---- + +def test_bulk_liste_zaehlt_bestand_wie_einzeln(db): + # products_to_out_bulk muss denselben Bestand liefern wie product_to_out – + # Lot-Summe für Mengen, Item-Anzahl für Einzelstücke. + reis = Product(name="Reis", base_unit=BaseUnit.gram, package_size=500) + db.add(reis) + db.flush() + a = _loc(db, "Regal") + object_add(db, reis, 1000, a.id, None) # 1000 g als Charge + powerbank = _individual_product(db, name="Powerbank") + for _ in range(2): + db.add(Item(uid=generate_uid(db), product_id=powerbank.id)) + db.commit() + + einzeln = {product_to_out(db, p).id: product_to_out(db, p).stock for p in [reis, powerbank]} + gebuendelt = {o.id: o.stock for o in products_to_out_bulk(db, [reis, powerbank])} + assert gebuendelt == einzeln + assert gebuendelt[reis.id] == 1000 + assert gebuendelt[powerbank.id] == 2 + + def test_neue_oberkategorie_ist_gegenstand(db): out = create_category(CategoryCreate(name="Werkzeug"), db, None) assert out.tracking == CategoryTracking.object