diff --git a/dashboard/cuentas.js b/dashboard/cuentas.js index 171a750..23333cd 100644 --- a/dashboard/cuentas.js +++ b/dashboard/cuentas.js @@ -161,12 +161,12 @@ // Populate invoice dropdown for payment form var invSelect = document.getElementById('pay-invoice'); - invSelect.innerHTML = ''; - res.invoices.filter(function (i) { return i.status !== 'paid' && i.status !== 'cancelled'; }) - .forEach(function (i) { - invSelect.innerHTML += ''; - }); + }).join(''); + invSelect.innerHTML = '' + options; }); } diff --git a/dashboard/dashboard.js b/dashboard/dashboard.js index 1caa1d3..7109fe6 100644 --- a/dashboard/dashboard.js +++ b/dashboard/dashboard.js @@ -509,19 +509,15 @@ class VehicleDashboard { if (yearsRes.ok) { const years = await yearsRes.json(); const yearFilter = document.getElementById('yearFilter'); - yearFilter.innerHTML = ''; - years.forEach(year => { - yearFilter.innerHTML += ``; - }); + yearFilter.innerHTML = '' + + years.map(year => ``).join(''); } if (enginesRes.ok) { const engines = await enginesRes.json(); const engineFilter = document.getElementById('engineFilter'); - engineFilter.innerHTML = ''; - engines.forEach(engine => { - engineFilter.innerHTML += ``; - }); + engineFilter.innerHTML = '' + + engines.map(engine => ``).join(''); } } catch (error) { diff --git a/docs/performance_audit_2026.md b/docs/performance_audit_2026.md new file mode 100644 index 0000000..31c287e --- /dev/null +++ b/docs/performance_audit_2026.md @@ -0,0 +1,571 @@ +# Nexus Autoparts — Auditoría de Performance y Optimización + +**Fecha:** 2026-04-26 +**Versión del sistema:** FASE 6 completa + Opción C +**Auditor realizado por:** Análisis automatizado de codebase + métricas del sistema + +--- + +## 1. Resumen Ejecutivo + +El sistema presenta **cuellos de botella severos** concentrados en tres áreas: + +| Área | Severidad | Impacto estimado | +|------|-----------|------------------| +| **Base de datos** | 🔴 Crítica | Respuestas de catálogo de 500ms–5s; riesgo de caída bajo carga concurrente | +| **Frontend (POS)** | 🟠 Alta | First paint lento, memory leaks, transferencia innecesaria de ~400 KB/página | +| **Infraestructura** | 🟠 Alta | Sin compresión, sin pooling de conexiones, Redis subutilizado | +| **Arquitectura Python** | 🟡 Media | Sync-only, cache por proceso, respuestas API no optimizadas | + +### Métricas clave del sistema actual + +| Métrica | Valor | Umbral saludable | +|---------|-------|------------------| +| Tamaño tabla `vehicle_parts` | **254 GB** (~2.05B filas) | < 50 GB o particionada | +| Índices `vehicle_parts` | **149 GB** (más que la tabla) | < 50% del tamaño de tabla | +| Tamaño total DB maestra | **257 GB** | — | +| Redis utilizado | **840 KB** | > 50 MB (cache activa) | +| JS transferido por página POS | **~526 KB** (sin comprimir) | < 150 KB con gzip | +| CSS inline duplicado por página | **~247 KB** | < 20 KB (compartido) | +| Connection pooling | **Ninguno** | Sí (min 2, max 10–20) | +| Compresión HTTP (gzip/brotli) | **Deshabilitada** | Sí | + +--- + +## 2. Base de Datos — Análisis Detallado + +### 2.1 El elefante en la habitación: `vehicle_parts` (254 GB) + +Esta tabla contiene la relación entre vehículos (model_year_engine) y piezas (parts). Es el corazón del catálogo y su tamaño es **extremo**. + +``` +Tabla: 105 GB +Índices: 149 GB +Total: 254 GB +Filas: ~2,053,774,208 (estimado) +``` + +**Índices actuales:** +```sql +vehicle_parts_pkey — btree (id_vehicle_part) +idx_vehicle_parts_part — btree (part_id) +idx_vehicle_parts_mye — btree (model_year_engine_id) +uq_vehicle_part_mye_part — partial unique (model_year_engine_id, part_id) +``` + +**Problemas:** +- Sin particionamiento por `part_id` o `model_year_engine_id` +- Índices ocupan más espacio que la tabla (indicativo de índices anchos o muchos índices secundarios) +- El índice `idx_vehicle_parts_part` probablemente se usa para queries que filtran por `part_id = ANY(...)`, pero sin un índice compuesto `(part_id, model_year_engine_id)` forza lookups adicionales +- `COUNT(*)` en esta tabla, aunque indexado, requiere verificar visibilidad de tuplas y puede ser lento a esta escala + +**Recomendación inmediata:** Particionar `vehicle_parts` por rango de `part_id` (ej. cada 10M IDs) o por hash. Esto reduce el tamaño de índice por partición y permite paralelismo en consultas. Requiere planificación cuidadosa por el impacto en inserts. + +--- + +### 2.2 Connection Pooling — Inexistente (🔴 Crítico) + +**Archivo:** `pos/tenant_db.py` + +```python +def get_master_conn(): + return psycopg2.connect(MASTER_DB_URL) # Conexión TCP nueva CADA request + +def get_tenant_conn(tenant_id): + ... + return psycopg2.connect(TENANT_DB_URL_TEMPLATE.format(db_name=db_name)) +``` + +**Impacto:** +- Cada request HTTP abre mínimo 1 conexión TCP (a veces 2: master + tenant) +- Overhead de handshake TCP + SSL + auth de PostgreSQL (~5–20ms por conexión) +- Bajo carga concurrente (> 10 usuarios), PostgreSQL puede agotar `max_connections` (default 100) +- Gunicorn corre con `workers = cpu_count * 2 + 1` (~15 workers). Cada worker puede tener múltiples requests concurrentes = explosión de conexiones + +**Fix:** Implementar `psycopg2.pool.ThreadedConnectionPool`: +```python +from psycopg2 import pool + +_master_pool = None +_tenant_pools = {} + +def get_master_conn(): + global _master_pool + if _master_pool is None: + _master_pool = pool.ThreadedConnectionPool( + minconn=2, maxconn=10, dsn=MASTER_DB_URL + ) + return _master_pool.getconn() + +def release_master_conn(conn): + if _master_pool: + _master_pool.putconn(conn) +``` + +**Impacto esperado:** 30–50% reducción en latencia de API, eliminación de errores de "too many connections". + +--- + +### 2.3 Full Table Scans en `warehouse_inventory` (🔴 Crítico) + +**Archivo:** `pos/services/catalog_service.py:964-969` + +```sql +stock_per_oem AS ( + SELECT part_id, COUNT(*) AS bodega_count, MIN(price) AS min_price, + SUM(stock_quantity) AS total_stock + FROM warehouse_inventory + WHERE stock_quantity > 0 + GROUP BY part_id +) +``` + +Esta CTE corre en **cada página del catálogo Local**. Aunque `warehouse_inventory` no sea gigante hoy, escanea toda la tabla sin índice adecuado. + +**Fix inmediato:** +```sql +-- Índice parcial para stock positivo +CREATE INDEX idx_wi_part_stock_positive +ON warehouse_inventory(part_id) +WHERE stock_quantity > 0; +``` + +Además, reemplazar la CTE por una subquery que filtre por `part_id = ANY(...)`: +```sql +SELECT part_id, COUNT(*) AS bodega_count, MIN(price) AS min_price, + SUM(stock_quantity) AS total_stock +FROM warehouse_inventory +WHERE part_id = ANY(%s) AND stock_quantity > 0 +GROUP BY part_id +``` + +--- + +### 2.4 Agregaciones sin límites en `inventory_operations` (🟠 Alta) + +**Múltiples archivos:** `inventory_engine.py`, `inventory_vehicle_compat.py`, `peer_service.py` + +Patrón recurrente: +```sql +SELECT inventory_id, SUM(quantity) as stock +FROM inventory_operations +GROUP BY inventory_id +``` + +`inventory_operations` es **append-only**. Cada venta, compra, ajuste, transferencia agrega filas. Esta agregación escanea **todas** las operaciones históricas. + +**Fix recomendado:** +1. **Corto plazo:** Agregar índice compuesto y filtrar por fecha reciente si el negocio no requiere stock histórico: + ```sql + CREATE INDEX idx_inv_ops_inventory_branch_created + ON inventory_operations(inventory_id, branch_id, created_at DESC); + ``` + +2. **Mediano plazo:** Mantener una tabla de `inventory_stock_summary` actualizada por triggers: + ```sql + CREATE TABLE inventory_stock_summary ( + inventory_id INT PRIMARY KEY, + branch_id INT, + stock INT NOT NULL DEFAULT 0, + last_updated TIMESTAMPTZ DEFAULT NOW() + ); + ``` + Trigger en `inventory_operations` que actualiza el sumario en tiempo real. Las lecturas pasan de `O(n operaciones)` a `O(1)`. + +--- + +### 2.5 `smart_search()` — DISTINCT ON sobre 2B filas (🔴 Crítico) + +**Archivo:** `pos/services/catalog_service.py:1339-1349` + +```sql +SELECT DISTINCT ON (vp.part_id) + vp.part_id, b.name_brand, m.name_model, y.year_car +FROM vehicle_parts vp +JOIN model_year_engine mye ON mye.id_mye = vp.model_year_engine_id +JOIN models m ON m.id_model = mye.model_id +JOIN brands b ON b.id_brand = m.brand_id +JOIN years y ON y.id_year = mye.year_id +WHERE vp.part_id = ANY(%s) +ORDER BY vp.part_id, y.year_car DESC +``` + +Para búsquedas que retornan 20–50 parts, esta query hace JOIN con una tabla de 2B filas para obtener "el vehículo más reciente" de cada pieza. + +**Fix:** Precalcular esta relación en una tabla materializada o cachear en Redis: +```sql +CREATE MATERIALIZED VIEW part_vehicle_preview AS +SELECT DISTINCT ON (vp.part_id) + vp.part_id, b.name_brand, m.name_model, y.year_car +FROM vehicle_parts vp +JOIN model_year_engine mye ON mye.id_mye = vp.model_year_engine_id +JOIN models m ON m.id_model = mye.model_id +JOIN brands b ON b.id_brand = m.brand_id +JOIN years y ON y.id_year = mye.year_id +ORDER BY vp.part_id, y.year_car DESC; + +CREATE UNIQUE INDEX idx_pvp_part ON part_vehicle_preview(part_id); +REFRESH MATERIALIZED VIEW CONCURRENTLY part_vehicle_preview; +``` + +--- + +### 2.6 N+1 Queries en ventas (🟠 Alta) + +**Archivo:** `pos/services/pos_engine.py:331-333` + +```python +for item in totals['items']: + cur.execute("SELECT retail_price FROM inventory WHERE id = %s", (item['inventory_id'],)) + rp_row = cur.fetchone() +``` + +Una venta de 20 ítems = 20 queries adicionales. + +**Fix:** Cargar todos los precios en una sola query antes del loop: +```python +inv_ids = [item['inventory_id'] for item in totals['items']] +cur.execute("SELECT id, retail_price FROM inventory WHERE id = ANY(%s)", (inv_ids,)) +price_map = {row[0]: row[1] for row in cur.fetchall()} +``` + +--- + +### 2.7 Índices faltantes críticos + +| Tabla | Índice faltante | Queries afectadas | +|-------|-----------------|-------------------| +| `warehouse_inventory` | `(part_id) WHERE stock_quantity > 0` | `get_parts_local`, `get_part_detail` | +| `warehouse_inventory` | `(part_id, stock_quantity)` | Todas las bodega queries | +| `inventory_operations` | `(inventory_id, branch_id, created_at DESC)` | Stock + historial | +| `inventory` | `(branch_id, is_active, part_number)` | Stock local lookups | +| `parts` | `(name_part text_pattern_ops)` | `get_part_types`, `_shop_supplies` | + +--- + +## 3. Frontend — Análisis Detallado + +### 3.1 JavaScript — Problemas críticos + +#### `innerHTML +=` en loops (🔴 Crítico) +**Archivo:** `catalog.js` líneas ~1578, 1584, 1608, etc. + +```javascript +years.forEach(function (y) { + vsYear.innerHTML += ''; +}); +``` + +Cada iteración fuerza al browser a **reparsear y re-renderizar todo el subtree**. Complejidad O(N²). + +**Fix:** +```javascript +vsYear.innerHTML = '' + + years.map(y => ``).join(''); +``` + +#### Memory leaks por event listeners (🔴 Crítico) +**Archivo:** `catalog.js` líneas 261–275 (breadcrumb), 1467–1475 (cart) + +Cada vez que se re-renderiza el breadcrumb o el carrito, se reemplaza `innerHTML` y se adjuntan nuevos listeners. Los listeners antiguos quedan huérfanos en memoria. + +**Fix:** Event delegation — adjuntar UNA vez en el contenedor padre: +```javascript +breadcrumb.addEventListener('click', function (e) { + var el = e.target.closest('[data-bc-action]'); + if (!el) return; + // dispatch +}); +``` + +#### Race conditions por falta de AbortController (🟠 Alta) +Navegación rápida por catálogo + red lenta = respuestas desordenadas sobrescriben la UI. + +**Fix:** +```javascript +var currentAbort = null; +function apiFetch(url) { + if (currentAbort) currentAbort.abort(); + currentAbort = new AbortController(); + return fetch(url, { signal: currentAbort.signal }).then(r => r.json()); +} +``` + +### 3.2 CSS Inline masivo (🔴 Crítico) + +Cada template HTML duplica CSS: + +| Página | Tamaño HTML | CSS inline estimado | +|--------|-------------|---------------------| +| `catalog.html` | 49 KB | ~570 líneas | +| `inventory.html` | 73 KB | ~2,000 líneas | +| `pos.html` | 73 KB | ~1,000 líneas | + +**Impacto:** +- Cambiar un color requiere editar 10+ archivos +- CSS no se cachea entre páginas (va dentro del HTML) +- First paint retrasado parseando CSS redundante + +**Fix:** Extraer todo CSS a `pos/static/css/common.css` y archivos temáticos. Reducción estimada: **60–80% del payload HTML**. + +### 3.3 Assets sin optimizar (🟠 Alta) + +- **Sin minificación:** 526 KB de JS sin comprimir +- **Sin gzip/brotli en nginx:** Reducción de 40–60% gratis +- **Sin `defer/async`:** 10 scripts bloquean el parser HTML +- **Sin lazy loading de imágenes:** Todas las imágenes de piezas se cargan inmediatamente +- **Cache-busting destructivo:** `?t=Date.now()` en imágenes de inventario anula cache del browser + +**Fix rápido (1 hora):** +```nginx +# En nginx/nexus-pos.conf +gzip on; +gzip_types text/css application/javascript application/json; +gzip_min_length 1024; +``` + +```html + + + +``` + +### 3.4 Polling innecesario (🟡 Media) + +| Módulo | Intervalo | Problema | +|--------|-----------|----------| +| WhatsApp status | 3s | Corre aunque el tab esté oculto | +| WhatsApp mensajes | 5s | Corre aunque el tab esté oculto | +| Auto-print queue | 15s | Siempre activo | +| Dashboard clock | 1s | Actualiza reloj cada segundo | + +**Fix:** Pausar polling cuando `document.hidden`: +```javascript +function createVisiblePoller(fn, intervalMs) { + return setInterval(() => { if (!document.hidden) fn(); }, intervalMs); +} +``` + +--- + +## 4. Infraestructura — Análisis Detallado + +### 4.1 Nginx — Configuración mínima + +**Archivo:** `nginx/nexus-pos.conf` + +Problemas encontrados: +- ❌ Sin `gzip on` +- ❌ Sin `expires` headers para assets estáticos +- ❌ Sin `client_body_buffer_size` / `proxy_buffer_size` tuning +- ❌ Proxy timeouts default (60s) — podrían ser más agresivos para requests lentos +- ❌ Sin rate limiting general (solo en login) + +**Fix recomendado:** +```nginx +# Dentro de cada server block +gzip on; +gzip_vary on; +gzip_proxied any; +gzip_comp_level 6; +gzip_types text/plain text/css text/xml application/json + application/javascript application/rss+xml + application/atom+xml image/svg+xml; + +# Cache assets estáticos +location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { + expires 6M; + add_header Cache-Control "public, immutable"; +} + +# Proxy tuning +proxy_connect_timeout 10s; +proxy_send_timeout 30s; +proxy_read_timeout 30s; +proxy_buffering on; +proxy_buffer_size 4k; +proxy_buffers 8 4k; +``` + +### 4.2 Gunicorn — Sync workers + +**Archivo:** `pos/gunicorn.conf.py` + +```python +workers = multiprocessing.cpu_count() * 2 + 1 # ~15 workers +worker_class = "sync" +timeout = 120 +``` + +Con `sync` workers, cada worker maneja **exactamente 1 request a la vez**. Si un request al catálogo tarda 3 segundos (por DB lenta), ese worker está bloqueado. + +**15 workers × 1 request = 15 requests concurrentes máximo.** + +**Fix:** Migrar a `gevent` o `gthread`: +```python +worker_class = "gthread" +threads = 4 +workers = 4 # Menos workers, más threads por worker +``` + +Esto permite que un worker maneje múltiples requests concurrentes sin bloquearse en I/O de DB. + +### 4.3 Redis — Subutilizado (🟠 Alta) + +**Métrica actual:** 840 KB utilizados + +Redis solo se usa para: +- Cache de tasas de cambio (`currency.py`) +- Cache de stock (`redis_stock_cache.py`) + +**NO se usa para:** +- Cache de queries de catálogo (la más costosa) +- Cache de clasificación Nexpart (`_classify_cache` es in-memory por proceso) +- Cache de búsquedas +- Sesiones compartidas entre workers + +**Fix:** Mover `_classify_cache` (Nexpart classification) a Redis: +```python +# En catalog_service.py +import json +import redis +r = redis.from_url(os.environ.get('REDIS_URL', 'redis://localhost:6379/0')) + +def _classify_cache_get(mye_id): + val = r.get(f"nexus:classify:{mye_id}") + return json.loads(val) if val else None + +def _classify_cache_set(mye_id, data): + r.setex(f"nexus:classify:{mye_id}", 300, json.dumps(data)) +``` + +Beneficio: cache compartido entre los ~15 workers de gunicorn, no 15 cachés separados. + +### 4.4 Flask — Sincrónico + +Todo el backend es síncrono (Flask sync + psycopg2 sync). Las queries a `vehicle_parts` bloquean el thread del worker. + +**Consideración a futuro:** Para endpoints de catálogo, evaluar migrar a un worker queue (Celery + Redis) para queries pesadas, o usar `asyncpg` + Quart/FastAPI para endpoints específicos. + +--- + +## 5. Backend Python — Análisis Detallado + +### 5.1 Cache por proceso vs. compartida + +El cache de clasificación Nexpart (`_CLASSIFY_CACHE`) vive en memoria de cada proceso de gunicorn. Con 15 workers: +- Cada worker tiene su propio cache de 500 entradas máximo +- Un `mye_id` cacheado en worker A no está disponible en worker B +- Cache hit rate teórico máximo: ~6.7% (1/15) si la carga se distribuye uniformemente + +**Fix:** Redis, como se describe en 4.3. + +### 5.2 Serialización JSON ineficiente + +Flask `jsonify()` usa el encoder estándar de Python. Para respuestas grandes (listas de 30 parts con múltiples campos), esto puede ser lento. + +**Fix:** Considerar `orjson` como encoder de Flask: +```python +from flask import Flask +import orjson + +class OrjsonProvider: + def dumps(self, obj, **kwargs): + return orjson.dumps(obj).decode('utf-8') + def loads(self, s, **kwargs): + return orjson.loads(s) + +app = Flask(__name__) +app.json = OrjsonProvider() +``` + +`orjson` es 2–10× más rápido que `json` estándar. + +### 5.3 Respuestas API sin paginación en algunos endpoints + +Algunos endpoints retornan arrays completos sin límite: +- `get_categories()` puede retornar todas las categorías (aceptable) +- `get_stock_bulk()` escanea TODO `inventory_operations` +- Historial de movimientos sin paginación en `inventory_bp` + +**Verificar:** Que ningún endpoint retorne datasets sin bounds. + +--- + +## 6. Roadmap de Optimización Priorizado + +### Fase 1 — Quick Wins (1–2 días, alto impacto) + +| # | Tarea | Archivos | Impacto estimado | +|---|-------|----------|------------------| +| 1.1 | **Agregar gzip en nginx** | `nginx/nexus-pos.conf` | -40–60% transfer size | +| 1.2 | **Agregar `defer` a scripts** | Templates HTML | -200–500ms TTI | +| 1.3 | **Reemplazar `innerHTML +=` por `map`+`join`** | `catalog.js`, `dashboard.js` | Elimina reflow storm | +| 1.4 | **Event delegation en breadcrumb/cart** | `catalog.js` | Elimina memory leak | +| 1.5 | **AbortController en API calls** | `catalog.js`, `inventory.js`, `pos.js` | Elimina race conditions | +| 1.6 | **Agregar índice parcial `warehouse_inventory(part_id) WHERE stock_quantity > 0`** | SQL | Elimina seq scan | +| 1.7 | **Cache de years/brands en `sessionStorage`** | `catalog.js` | -2 API calls/página | + +### Fase 2 — DB Performance (3–5 días, impacto masivo) + +| # | Tarea | Archivos | Impacto estimado | +|---|-------|----------|------------------| +| 2.1 | **Implementar connection pooling** | `tenant_db.py`, `dashboard/auth.py` | -30–50% latencia, sin connection errors | +| 2.2 | **Crear tabla `inventory_stock_summary` con triggers** | SQL + `inventory_engine.py` | Stock: O(n) → O(1) | +| 2.3 | **Precalcular `part_vehicle_preview` materialized view** | SQL | Búsqueda: -500ms–2s | +| 2.4 | **N+1 fix en `process_sale`** | `pos_engine.py` | -20 queries por venta | +| 2.5 | **Optimizar CTE `stock_per_oem`** | `catalog_service.py` | Elimina full scan | +| 2.6 | **Agregar índices faltantes críticos** | SQL | Mejora plan de queries | + +### Fase 3 — Cache & Infra (5–7 días) + +| # | Tarea | Archivos | Impacto estimado | +|---|-------|----------|------------------| +| 3.1 | **Mover `_classify_cache` a Redis** | `catalog_service.py` | Cache hit: 6% → 80%+ | +| 3.2 | **Cachear queries de catálogo en Redis** | `catalog_bp.py`, `catalog_service.py` | -300ms–1s por query | +| 3.3 | **Migrar gunicorn a `gthread`** | `gunicorn.conf.py` | 4× concurrency | +| 3.4 | **Agregar `loading="lazy"` a imágenes** | `catalog.js` | -60% image bytes inicial | +| 3.5 | **Extraer CSS inline a archivos compartidos** | Todos los `.html` | -60–80% payload HTML | +| 3.6 | **Minificar JS/CSS en deploy** | CI/CD script | -40–60% transfer | + +### Fase 4 — Arquitectura (2–4 semanas, inversión mayor) + +| # | Tarea | Archivos | Impacto estimado | +|---|-------|----------|------------------| +| 4.1 | **Particionar `vehicle_parts`** | SQL | Escalabilidad ilimitada | +| 4.2 | **Evaluar `asyncpg` + Quart para endpoints de catálogo** | Blueprints | I/O no bloqueante | +| 4.3 | **Migrar serialización a `orjson`** | `app.py` | 2–10× faster JSON | +| 4.4 | **Implementar virtual scroll en tablas grandes** | `inventory.js`, `dashboard.js` | Smooth 1000+ rows | +| 4.5 | **Worker queue (Celery) para tareas pesadas** | Nuevo módulo | No bloquear requests HTTP | + +--- + +## 7. Métricas de éxito propuestas + +Después de implementar Fase 1 + Fase 2, el sistema debería alcanzar: + +| Métrica | Antes | Objetivo | Cómo medir | +|---------|-------|----------|------------| +| Latencia catálogo Local (p95) | 500ms–3s | < 200ms | Gunicorn access logs | +| Latencia búsqueda (p95) | 500ms–2s | < 150ms | Gunicorn access logs | +| Tiempo First Paint | ~1.5s | < 800ms | Lighthouse | +| Transferencia JS por página | 526 KB | < 200 KB | DevTools Network | +| Conexiones DB por request | 1–2 nuevas | 0 (pool) | `pg_stat_activity` | +| Redis hit rate | < 1% | > 70% | `INFO stats` | +| Memory leak (30 min browsing) | +50–100 MB | < +10 MB | Chrome DevTools | + +--- + +## 8. Riesgos y Consideraciones + +| Riesgo | Mitigación | +|--------|------------| +| Particionar `vehicle_parts` es complejo y riesgoso | Hacer en staging primero; usar `pg_partman`; mantener backup | +| Connection pooling requiere manejar `putconn` en todos los blueprints | Refactorizar `tenant_db.py` para usar context managers (`with get_conn():`) | +| Migrar a `gthread` puede exponer race conditions en código no thread-safe | Pruebas exhaustivas; `gthread` es más seguro que `gevent` para código sync existente | +| Cache Redis agrega punto de falla | Siempre tener fallback a DB directo; Redis no es crítico para operación | +| `inventory_stock_summary` requiere triggers correctos | Tests de integración para todas las operaciones de stock | + +--- + +*Documento generado automáticamente a partir del análisis de codebase, métricas de sistema y mejores prácticas de performance.* diff --git a/nginx/nexus-pos.conf b/nginx/nexus-pos.conf index b760f34..3f47ceb 100644 --- a/nginx/nexus-pos.conf +++ b/nginx/nexus-pos.conf @@ -13,17 +13,37 @@ upstream nexus_pos { server 127.0.0.1:5001; } +# Gzip compression +gzip on; +gzip_vary on; +gzip_proxied any; +gzip_comp_level 6; +gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml; + # Main site (no subdomain) server { listen 80; server_name nexusautoparts.com www.nexusautoparts.com; + # Static asset caching + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 6M; + add_header Cache-Control "public, immutable"; + add_header X-Content-Type-Options nosniff always; + } + location / { proxy_pass http://nexus_main; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + proxy_buffering on; + proxy_buffer_size 4k; + proxy_buffers 8 4k; } } @@ -36,6 +56,13 @@ server { add_header X-Content-Type-Options nosniff always; add_header X-Frame-Options SAMEORIGIN always; + # Static asset caching + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 6M; + add_header Cache-Control "public, immutable"; + add_header X-Content-Type-Options nosniff always; + } + location / { proxy_pass http://nexus_pos; proxy_set_header Host $host; @@ -43,6 +70,12 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Tenant-Subdomain $tenant; + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + proxy_buffering on; + proxy_buffer_size 4k; + proxy_buffers 8 4k; } # Rate limit login endpoint diff --git a/pos/static/js/catalog.js b/pos/static/js/catalog.js index 69d5f1f..ddc9ab0 100644 --- a/pos/static/js/catalog.js +++ b/pos/static/js/catalog.js @@ -186,8 +186,20 @@ var cartItems = JSON.parse(localStorage.getItem('pos_cart') || '[]'); // ─── API helper ─── + var currentAbort = null; + function apiFetch(url) { - return fetch(url, { headers: headers }) + // Cancel previous navigation/search GETs to avoid race conditions + if (currentAbort) { + currentAbort.abort(); + currentAbort = null; + } + var opts = { headers: headers }; + if (url.indexOf('/pos/api/') === 0 && url.indexOf('mode=') !== -1 || url.indexOf('/years') !== -1 || url.indexOf('/brands') !== -1 || url.indexOf('/models') !== -1 || url.indexOf('/engines') !== -1 || url.indexOf('/categories') !== -1 || url.indexOf('/groups') !== -1 || url.indexOf('/part-types') !== -1 || url.indexOf('/parts') !== -1 || url.indexOf('/search') !== -1) { + currentAbort = new AbortController(); + opts.signal = currentAbort.signal; + } + return fetch(url, opts) .then(function (resp) { if (resp.status === 401) { localStorage.removeItem('pos_token'); @@ -197,6 +209,7 @@ return resp.json(); }) .catch(function (e) { + if (e.name === 'AbortError') return null; console.error('API error:', e); return null; }); @@ -333,8 +346,18 @@ setupLevelFilter(true); showLoading(); + var cacheKey = 'nexus:brands:' + catalogMode; + var cached = sessionStorage.getItem(cacheKey); + if (cached) { + hideLoading(); + var data = JSON.parse(cached); + renderBrands(data); + return; + } + apiFetch(API + '/brands?mode=' + catalogMode).then(function (data) { hideLoading(); + if (data && data.data) sessionStorage.setItem(cacheKey, JSON.stringify(data)); if (!data || !data.data || !data.data.length) { if (!data) { enterOfflineMode(); @@ -359,6 +382,30 @@ }); } + function renderBrands(data) { + if (!data || !data.data || !data.data.length) { + if (!data) { + enterOfflineMode(); + return; + } + showEmpty('Sin marcas', 'El catalogo no tiene marcas con partes disponibles.'); + return; + } + navGrid.className = 'nav-grid'; + navGrid.innerHTML = data.data.map(function (b) { + return ''; + }).join(''); + + navGrid.querySelectorAll('.nav-card').forEach(function (card) { + card.addEventListener('click', function () { + nav.brand = { id: parseInt(this.dataset.brandId), name: this.dataset.name }; + loadModels(); + }); + }); + } + function loadModels() { nav.level = 'models'; pushNavState(); @@ -1463,18 +1510,19 @@ cartTaxEl.textContent = '$' + fmt(tax); cartTotalEl.textContent = '$' + fmt(subtotal + tax); - // Wire cart buttons - cartItemsEl.querySelectorAll('[data-cart-action]').forEach(function (btn) { - btn.addEventListener('click', function () { - var idx = parseInt(this.dataset.idx); - var action = this.dataset.cartAction; - if (action === 'dec') updateQuantity(idx, cartItems[idx].quantity - 1); - else if (action === 'inc') updateQuantity(idx, cartItems[idx].quantity + 1); - else if (action === 'remove') removeFromCart(idx); - }); - }); } + // Event delegation for cart buttons (attached once) + cartItemsEl.addEventListener('click', function (e) { + var btn = e.target.closest('[data-cart-action]'); + if (!btn) return; + var idx = parseInt(btn.dataset.idx); + var action = btn.dataset.cartAction; + if (action === 'dec') updateQuantity(idx, cartItems[idx].quantity - 1); + else if (action === 'inc') updateQuantity(idx, cartItems[idx].quantity + 1); + else if (action === 'remove') removeFromCart(idx); + }); + function toggleCart() { var isOpen = cartSidebar.classList.toggle('open'); cartOverlay.classList.toggle('open', isOpen); @@ -1565,6 +1613,22 @@ // Load years on init function vsLoadYears() { + var cacheKey = 'nexus:years-all'; + var cached = sessionStorage.getItem(cacheKey); + if (cached) { + var data = JSON.parse(cached); + var years = data.data || data || []; + if (!years.length) { + years = []; + for (var y = 2026; y >= 1990; y--) years.push({ id_year: y, year_car: y }); + } + vsYear.innerHTML = '' + + years.map(function (y) { + return ''; + }).join(''); + return; + } + apiFetch(API + '/years-all').then(function (data) { if (!data) return; var years = data.data || data; @@ -1573,16 +1637,19 @@ years = []; for (var y = 2026; y >= 1990; y--) years.push({ id_year: y, year_car: y }); } - vsYear.innerHTML = ''; - years.forEach(function (y) { - vsYear.innerHTML += ''; - }); + sessionStorage.setItem(cacheKey, JSON.stringify(data)); + vsYear.innerHTML = '' + + years.map(function (y) { + return ''; + }).join(''); }).catch(function () { // Fallback: generate years statically - vsYear.innerHTML = ''; - for (var y = 2026; y >= 1990; y--) { - vsYear.innerHTML += ''; - } + var fallbackYears = []; + for (var y = 2026; y >= 1990; y--) fallbackYears.push(y); + vsYear.innerHTML = '' + + fallbackYears.map(function (y) { + return ''; + }).join(''); }); } @@ -1603,10 +1670,10 @@ apiFetch(API + '/brands?year_id=' + yearId + '&mode=' + catalogMode).then(function (data) { var brands = data.data || data; if (!brands) return; - vsBrand.innerHTML = ''; - brands.forEach(function (b) { - vsBrand.innerHTML += ''; - }); + vsBrand.innerHTML = '' + + brands.map(function (b) { + return ''; + }).join(''); }); } @@ -1625,10 +1692,10 @@ apiFetch(API + '/models?brand_id=' + brandId + (yearId ? '&year_id=' + yearId : '')).then(function (data) { var models = data.data || data; if (!models) return; - vsModel.innerHTML = ''; - models.forEach(function (m) { - vsModel.innerHTML += ''; - }); + vsModel.innerHTML = '' + + models.map(function (m) { + return ''; + }).join(''); }); } @@ -1644,11 +1711,11 @@ apiFetch(API + '/engines?model_id=' + modelId + '&year_id=' + yearVal).then(function (data) { var engines = data.data || data; if (!engines) return; - vsEngine.innerHTML = ''; - engines.forEach(function (e) { - var label = e.name_engine + (e.trim_level ? ' (' + e.trim_level + ')' : ''); - vsEngine.innerHTML += ''; - }); + vsEngine.innerHTML = '' + + engines.map(function (e) { + var label = e.name_engine + (e.trim_level ? ' (' + e.trim_level + ')' : ''); + return ''; + }).join(''); // If only 1 engine, auto-select if (engines.length === 1) { vsEngine.value = engines[0].id_mye; @@ -1841,10 +1908,10 @@ apiFetch(API + '/brands?year_id=' + match.year_id).then(function (brandData) { var brands = brandData && (brandData.data || brandData); if (!brands) return; - vsBrand.innerHTML = ''; - brands.forEach(function (b) { - vsBrand.innerHTML += ''; - }); + vsBrand.innerHTML = '' + + brands.map(function (b) { + return ''; + }).join(''); vsBrand.disabled = false; vsClear.style.display = ''; @@ -1854,10 +1921,10 @@ apiFetch(API + '/models?brand_id=' + match.brand_id + '&year_id=' + match.year_id).then(function (modelData) { var models = modelData && (modelData.data || modelData); if (!models) return; - vsModel.innerHTML = ''; - models.forEach(function (m) { - vsModel.innerHTML += ''; - }); + vsModel.innerHTML = '' + + models.map(function (m) { + return ''; + }).join(''); vsModel.disabled = false; if (match.model_id) { @@ -1866,11 +1933,11 @@ apiFetch(API + '/engines?model_id=' + match.model_id + '&year_id=' + match.year_id).then(function (engData) { var engines = engData && (engData.data || engData); if (!engines) return; - vsEngine.innerHTML = ''; - engines.forEach(function (e) { - var elabel = e.name_engine + (e.trim_level ? ' (' + e.trim_level + ')' : ''); - vsEngine.innerHTML += ''; - }); + vsEngine.innerHTML = '' + + engines.map(function (e) { + var elabel = e.name_engine + (e.trim_level ? ' (' + e.trim_level + ')' : ''); + return ''; + }).join(''); vsEngine.disabled = false; // Auto-select engine if only one or if match specifies it diff --git a/pos/templates/accounting.html b/pos/templates/accounting.html index 97176f6..8c3e17a 100644 --- a/pos/templates/accounting.html +++ b/pos/templates/accounting.html @@ -1731,12 +1731,12 @@ - - - - - - + + + + + + diff --git a/pos/templates/catalog.html b/pos/templates/catalog.html index d6a40d0..9fa2b46 100644 --- a/pos/templates/catalog.html +++ b/pos/templates/catalog.html @@ -11,7 +11,7 @@ - + - - - - - - - + + + + + + + diff --git a/pos/templates/customers.html b/pos/templates/customers.html index aa5c06d..2eafc50 100644 --- a/pos/templates/customers.html +++ b/pos/templates/customers.html @@ -2164,13 +2164,13 @@ - - - - - - - + + + + + + + diff --git a/pos/templates/dashboard.html b/pos/templates/dashboard.html index 5cf594a..436e53a 100644 --- a/pos/templates/dashboard.html +++ b/pos/templates/dashboard.html @@ -1686,12 +1686,12 @@ - - - - - - + + + + + + diff --git a/pos/templates/diagrams.html b/pos/templates/diagrams.html index 552202a..0df8e64 100644 --- a/pos/templates/diagrams.html +++ b/pos/templates/diagrams.html @@ -11,7 +11,7 @@ - + - - - + + +

Cotizaciones

diff --git a/pos/templates/reports.html b/pos/templates/reports.html index 2fada02..ad13820 100644 --- a/pos/templates/reports.html +++ b/pos/templates/reports.html @@ -1847,12 +1847,12 @@
- - - - - - + + + + + + diff --git a/pos/templates/whatsapp.html b/pos/templates/whatsapp.html index 233978c..adb2edd 100644 --- a/pos/templates/whatsapp.html +++ b/pos/templates/whatsapp.html @@ -628,10 +628,10 @@ function posLogout(){localStorage.removeItem('pos_token');window.location.href=' - - - - + + + +