FASE 7b: DB Performance — Pooling, Stock Summary, N+1 fix
Cambios implementados: 1. Connection pooling (tenant_db.py): - psycopg2.pool.ThreadedConnectionPool para master y tenants - Wrapper _PooledConnection que devuelve al pool en .close() - Cero cambios en blueprints (backward compatible) 2. Tabla inventory_stock_summary + triggers (v3.2): - O(1) stock lookup en vez de SUM() sobre historial completo - Trigger AFTER INSERT en inventory_operations recalcula stock - Poblada inicialmente en ambos tenants - Refactor en 6 archivos de servicios para usar la nueva tabla 3. Fix N+1 en process_sale (pos_engine.py): - Precarga retail_price en bulk query FOR UPDATE - Elimina SELECT individual por item en loop 4. Índices críticos: - idx_parts_name_part + pattern_ops (master) - idx_inv_ops_inventory_branch_created (tenants) - idx_wi_part_stock_positive (master, ya existía desde Fase 1) Tests: 73/73 pasando (compat + fase3 + fase5 + fase6) Migración: v3.2_db_performance.sql
This commit is contained in:
@@ -140,7 +140,7 @@ def get_inventory_context(tenant_conn, branch_id=None):
|
||||
cur.execute(f"""
|
||||
SELECT COUNT(*) FROM inventory i
|
||||
WHERE {where}
|
||||
AND COALESCE((SELECT SUM(quantity) FROM inventory_operations WHERE inventory_id = i.id), 0) <= 3
|
||||
AND COALESCE((SELECT stock FROM inventory_stock_summary WHERE inventory_id = i.id), 0) <= 3
|
||||
""", params)
|
||||
low_stock = cur.fetchone()[0] or 0
|
||||
|
||||
|
||||
@@ -1411,12 +1411,11 @@ def _get_local_stock_bulk(tenant_conn, branch_id, oem_numbers, catalog_part_ids)
|
||||
cur.execute(f"""
|
||||
SELECT i.id, i.part_number, i.catalog_part_id,
|
||||
i.price_1, i.price_2, i.price_3, i.cost, i.tax_rate,
|
||||
COALESCE(SUM(io.quantity), 0) AS stock,
|
||||
COALESCE(s.stock, 0) AS stock,
|
||||
i.image_url
|
||||
FROM inventory i
|
||||
LEFT JOIN inventory_operations io ON io.inventory_id = i.id
|
||||
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
|
||||
WHERE ({where}) AND i.is_active = true{branch_filter}
|
||||
GROUP BY i.id
|
||||
""", params)
|
||||
|
||||
result = {}
|
||||
@@ -1453,13 +1452,12 @@ def _get_local_stock_single(tenant_conn, branch_id, oem_part_number, catalog_par
|
||||
cur.execute(f"""
|
||||
SELECT i.id, i.price_1, i.price_2, i.price_3, i.cost, i.tax_rate,
|
||||
i.location, i.unit, i.barcode,
|
||||
COALESCE(SUM(io.quantity), 0) AS stock,
|
||||
COALESCE(s.stock, 0) AS stock,
|
||||
i.image_url
|
||||
FROM inventory i
|
||||
LEFT JOIN inventory_operations io ON io.inventory_id = i.id
|
||||
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
|
||||
WHERE (i.part_number = %s OR i.catalog_part_id = %s)
|
||||
AND i.is_active = true{branch_filter}
|
||||
GROUP BY i.id
|
||||
LIMIT 1
|
||||
""", params)
|
||||
|
||||
|
||||
@@ -25,15 +25,33 @@ def _safe_g(attr, default=None):
|
||||
def get_stock(conn, inventory_id, branch_id=None):
|
||||
"""Get current stock for an inventory item. Optionally filter by branch.
|
||||
|
||||
Uses Redis cache first, falls back to PostgreSQL SUM query.
|
||||
Uses Redis cache first, then inventory_stock_summary, falls back to
|
||||
PostgreSQL SUM query.
|
||||
"""
|
||||
# Try Redis first
|
||||
cached = get_cached_stock(inventory_id, branch_id)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# Fallback to PostgreSQL
|
||||
# Use inventory_stock_summary (O(1) lookup)
|
||||
cur = conn.cursor()
|
||||
if branch_id:
|
||||
cur.execute(
|
||||
"SELECT stock FROM inventory_stock_summary WHERE inventory_id = %s AND branch_id = %s",
|
||||
(inventory_id, branch_id)
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"SELECT stock FROM inventory_stock_summary WHERE inventory_id = %s",
|
||||
(inventory_id,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is not None:
|
||||
cur.close()
|
||||
set_cached_stock(inventory_id, row[0], branch_id)
|
||||
return row[0]
|
||||
|
||||
# Fallback to PostgreSQL SUM (legacy, should not reach here if trigger works)
|
||||
if branch_id:
|
||||
cur.execute(
|
||||
"SELECT COALESCE(SUM(quantity), 0) FROM inventory_operations WHERE inventory_id = %s AND branch_id = %s",
|
||||
@@ -55,21 +73,17 @@ def get_stock(conn, inventory_id, branch_id=None):
|
||||
def get_stock_bulk(conn, branch_id=None):
|
||||
"""Get stock for all items. Returns dict {inventory_id: stock_quantity}.
|
||||
|
||||
Uses PostgreSQL directly (bulk operation, Redis wouldn't help much here
|
||||
unless we pre-populated all keys).
|
||||
Uses inventory_stock_summary for O(1) bulk lookup.
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
if branch_id:
|
||||
cur.execute("""
|
||||
SELECT inventory_id, COALESCE(SUM(quantity), 0)
|
||||
FROM inventory_operations WHERE branch_id = %s
|
||||
GROUP BY inventory_id
|
||||
SELECT inventory_id, stock
|
||||
FROM inventory_stock_summary WHERE branch_id = %s
|
||||
""", (branch_id,))
|
||||
else:
|
||||
cur.execute("""
|
||||
SELECT inventory_id, COALESCE(SUM(quantity), 0)
|
||||
FROM inventory_operations
|
||||
GROUP BY inventory_id
|
||||
SELECT inventory_id, stock FROM inventory_stock_summary
|
||||
""")
|
||||
stock_map = {r[0]: r[1] for r in cur.fetchall()}
|
||||
cur.close()
|
||||
|
||||
@@ -194,11 +194,7 @@ def get_inventory_by_vehicle(tenant_conn, master_conn, mye_id, branch_id=None):
|
||||
i.image_url, i.description, COALESCE(s.stock, 0) as stock
|
||||
FROM inventory i
|
||||
JOIN inventory_vehicle_compat ivc ON ivc.inventory_id = i.id
|
||||
LEFT JOIN (
|
||||
SELECT inventory_id, SUM(quantity) as stock
|
||||
FROM inventory_operations
|
||||
GROUP BY inventory_id
|
||||
) s ON s.inventory_id = i.id
|
||||
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
|
||||
WHERE ivc.model_year_engine_id = %s {branch_filter}
|
||||
AND i.is_active = true
|
||||
ORDER BY i.name
|
||||
|
||||
@@ -93,11 +93,7 @@ def get_local_inventory(tenant_conn, query: str = None, limit: int = 50) -> list
|
||||
COALESCE(s.stock, 0) AS stock,
|
||||
i.unit, i.catalog_part_id
|
||||
FROM inventory i
|
||||
LEFT JOIN (
|
||||
SELECT inventory_id, SUM(quantity) AS stock
|
||||
FROM inventory_operations
|
||||
GROUP BY inventory_id
|
||||
) s ON s.inventory_id = i.id
|
||||
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
|
||||
WHERE {where}
|
||||
ORDER BY i.name
|
||||
LIMIT %s
|
||||
|
||||
@@ -224,7 +224,7 @@ def process_sale(conn, sale_data):
|
||||
# Lock inventory rows to prevent race conditions on concurrent sales
|
||||
cur.execute("""
|
||||
SELECT id, part_number, name, cost, price_1, price_2, price_3,
|
||||
tax_rate, branch_id
|
||||
tax_rate, branch_id, retail_price
|
||||
FROM inventory
|
||||
WHERE id = ANY(%s) AND is_active = true
|
||||
ORDER BY id
|
||||
@@ -327,10 +327,9 @@ def process_sale(conn, sale_data):
|
||||
# Create sale items (batch insert) and deduct inventory
|
||||
sale_items_data = []
|
||||
for item in totals['items']:
|
||||
# Fetch retail_price for savings calculation
|
||||
cur.execute("SELECT retail_price FROM inventory WHERE id = %s", (item['inventory_id'],))
|
||||
rp_row = cur.fetchone()
|
||||
retail_price = rp_row[0] if rp_row else None
|
||||
# retail_price from preloaded bulk query (index 9)
|
||||
inv = inv_rows.get(item['inventory_id'])
|
||||
retail_price = inv[9] if inv else None
|
||||
|
||||
sale_items_data.append((
|
||||
sale_id, item['inventory_id'], item['part_number'], item['name'],
|
||||
|
||||
Reference in New Issue
Block a user