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 <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user