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)
|
||||
|
||||
Reference in New Issue
Block a user