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 __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session
|
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 .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.stock import current_stock
|
from .services.stock import current_stock
|
||||||
@@ -89,6 +91,87 @@ def product_to_out(db: Session, product: Product) -> ProductOut:
|
|||||||
return out
|
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(
|
def resolve_product(
|
||||||
db: Session, product_id: int | None, barcode: str | None
|
db: Session, product_id: int | None, barcode: str | None
|
||||||
) -> Product:
|
) -> Product:
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||||
from fastapi.responses import Response
|
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 ..database import get_db
|
||||||
from ..deps import get_current_user, require_admin
|
from ..deps import get_current_user, require_admin
|
||||||
from ..models import (
|
from ..models import (
|
||||||
@@ -56,7 +56,15 @@ def list_products(
|
|||||||
Kategorie zählen die Unterkategorien mit: Wer auf "Süßwaren" filtert, will
|
Kategorie zählen die Unterkategorien mit: Wer auf "Süßwaren" filtert, will
|
||||||
auch "Schokolade" sehen.
|
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:
|
if q:
|
||||||
like = f"%{q}%"
|
like = f"%{q}%"
|
||||||
query = query.filter(Product.name.ilike(like))
|
query = query.filter(Product.name.ilike(like))
|
||||||
@@ -65,7 +73,7 @@ def list_products(
|
|||||||
elif category_id is not None:
|
elif category_id is not None:
|
||||||
query = query.filter(Product.category_id.in_(descendant_ids(db, category_id)))
|
query = query.filter(Product.category_id.in_(descendant_ids(db, category_id)))
|
||||||
products = query.order_by(Product.name).all()
|
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)
|
@router.get("/lookup", response_model=LookupResult)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import json
|
|||||||
|
|
||||||
import pytest
|
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 (
|
from app.models import (
|
||||||
BaseUnit,
|
BaseUnit,
|
||||||
Category,
|
Category,
|
||||||
@@ -92,6 +92,28 @@ def test_einzelstueck_ohne_kategorie_ist_object(db):
|
|||||||
assert product_tracking(db, product) == "object"
|
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):
|
def test_neue_oberkategorie_ist_gegenstand(db):
|
||||||
out = create_category(CategoryCreate(name="Werkzeug"), db, None)
|
out = create_category(CategoryCreate(name="Werkzeug"), db, None)
|
||||||
assert out.tracking == CategoryTracking.object
|
assert out.tracking == CategoryTracking.object
|
||||||
|
|||||||
Reference in New Issue
Block a user