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