FASE 4-5-6: Infraestructura, CRM, Service Orders, Notificaciones, Ahorro, Logistica, API Publica
FASE 4: - Redis cache de stock con fallback graceful - Multi-moneda (MXN/USD) con contabilidad en MXN - Proveedores y ordenes de compra completo - Meilisearch 1.5M+ partes indexadas - Metabase KPIs con dashboard auto-generado FASE 5: - CRM mejorado: activities, tags, loyalty program, analytics - Imagenes de partes: upload, resize, thumbnails WebP - Ordenes de servicio Kanban: received->diagnosis->repair->ready->delivered - Garantias/RMA, alertas de reorden, multi-sucursal - Stubs BNPL (APLAZO) y ERP Sync (Aspel/Contpaqi) FASE 6: - Notificaciones automaticas: push/WhatsApp/email/in-app - Reportes de ahorro vs retail_price - Logistica + tracking: DHL, FedEx, Estafeta, 99min, Uber - API Publica: API keys, rate limiting, catalog search Migraciones: v1.9-v3.0 Tests: 93/93 pasando Backup: nexus_backup_20260427_045859.tar.gz
This commit is contained in:
302
scripts/backup_selective.sh
Executable file
302
scripts/backup_selective.sh
Executable file
@@ -0,0 +1,302 @@
|
||||
#!/bin/bash
|
||||
# ============================================================
|
||||
# Nexus Autoparts — Selective Backup (No TecDoc)
|
||||
# Backs up schema + all data EXCEPT vehicle_parts fitments
|
||||
# Includes all tenant databases
|
||||
# ============================================================
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
info() { echo -e "${CYAN}[INFO]${NC} $*"; }
|
||||
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
err() { echo -e "${RED}[ERROR]${NC} $*"; }
|
||||
fatal() { err "$*"; exit 1; }
|
||||
|
||||
# ─── Configuration ─────────────────────────────────────────────────────────
|
||||
BACKUP_DIR="${BACKUP_DIR:-${PROJECT_DIR}/backups}"
|
||||
RETENTION_DAYS="${RETENTION_DAYS:-30}"
|
||||
MASTER_DB="${MASTER_DB:-nexus_autoparts}"
|
||||
DB_USER="${DB_USER:-nexus}"
|
||||
|
||||
# Load .env if exists
|
||||
if [[ -f "${PROJECT_DIR}/.env" ]]; then
|
||||
set -a
|
||||
source "${PROJECT_DIR}/.env"
|
||||
set +a
|
||||
fi
|
||||
|
||||
# Parse DB credentials from DATABASE_URL or MASTER_DB_URL
|
||||
DB_URL="${MASTER_DB_URL:-${DATABASE_URL:-}}"
|
||||
if [[ -n "$DB_URL" ]]; then
|
||||
# Extract components from postgresql://user:pass@host/dbname
|
||||
DB_HOST=$(echo "$DB_URL" | sed -n 's/.*@\([^:]*\).*/\1/p')
|
||||
DB_PORT=$(echo "$DB_URL" | sed -n 's/.*:\([0-9]*\)\/.*/\1/p')
|
||||
[[ -z "$DB_PORT" ]] && DB_PORT=5432
|
||||
fi
|
||||
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_NAME="nexus_backup_${TIMESTAMP}"
|
||||
BACKUP_PATH="${BACKUP_DIR}/${BACKUP_NAME}"
|
||||
|
||||
# ─── Pre-flight checks ─────────────────────────────────────────────────────
|
||||
check_prerequisites() {
|
||||
info "Checking prerequisites..."
|
||||
|
||||
if ! command -v pg_dump &>/dev/null; then
|
||||
fatal "pg_dump not found. Install: sudo apt install postgresql-client"
|
||||
fi
|
||||
|
||||
if ! command -v pg_dumpall &>/dev/null; then
|
||||
fatal "pg_dumpall not found. Install: sudo apt install postgresql-client"
|
||||
fi
|
||||
|
||||
# Test PostgreSQL connection
|
||||
if ! sudo -u postgres psql -c "SELECT 1" &>/dev/null; then
|
||||
fatal "Cannot connect to PostgreSQL. Is it running?"
|
||||
fi
|
||||
|
||||
# Create backup directory
|
||||
mkdir -p "$BACKUP_PATH"
|
||||
|
||||
ok "Prerequisites passed. Backup will be saved to: ${BACKUP_PATH}"
|
||||
}
|
||||
|
||||
# ─── Backup master schema (structure only) ─────────────────────────────────
|
||||
backup_master_schema() {
|
||||
info "Backing up master database schema (structure only)..."
|
||||
|
||||
local output="${BACKUP_PATH}/01_master_schema.sql"
|
||||
|
||||
sudo -u postgres pg_dump \
|
||||
--schema-only \
|
||||
--no-owner \
|
||||
--no-privileges \
|
||||
"$MASTER_DB" > "$output"
|
||||
|
||||
local size=$(du -h "$output" | cut -f1)
|
||||
ok "Master schema: ${size}"
|
||||
}
|
||||
|
||||
# ─── Backup master data (excluding vehicle_parts) ──────────────────────────
|
||||
backup_master_data() {
|
||||
info "Backing up master database data (excluding vehicle_parts)..."
|
||||
|
||||
local output="${BACKUP_PATH}/02_master_data.sql"
|
||||
local tables_file="${BACKUP_PATH}/_tables_to_backup.txt"
|
||||
|
||||
# Get list of tables EXCEPT vehicle_parts
|
||||
sudo -u postgres psql "$MASTER_DB" -Atc "
|
||||
SELECT tablename FROM pg_tables
|
||||
WHERE schemaname = 'public'
|
||||
AND tablename != 'vehicle_parts'
|
||||
ORDER BY tablename;
|
||||
" > "$tables_file"
|
||||
|
||||
local table_count=$(wc -l < "$tables_file")
|
||||
info "Found ${table_count} tables to backup (excluded: vehicle_parts)"
|
||||
|
||||
# Build --table arguments
|
||||
local table_args=""
|
||||
while IFS= read -r table; do
|
||||
[[ -n "$table" ]] && table_args="$table_args --table=$table"
|
||||
done < "$tables_file"
|
||||
|
||||
# Dump data only for selected tables
|
||||
sudo -u postgres pg_dump \
|
||||
--data-only \
|
||||
--no-owner \
|
||||
--no-privileges \
|
||||
$table_args \
|
||||
"$MASTER_DB" > "$output"
|
||||
|
||||
# Compress
|
||||
gzip -f "$output"
|
||||
local size=$(du -h "${output}.gz" | cut -f1)
|
||||
ok "Master data (no TecDoc): ${size}"
|
||||
|
||||
rm -f "$tables_file"
|
||||
}
|
||||
|
||||
# ─── Backup vehicle_parts schema only ──────────────────────────────────────
|
||||
backup_vehicle_parts_schema() {
|
||||
info "Backing up vehicle_parts schema only (structure, no data)..."
|
||||
|
||||
local output="${BACKUP_PATH}/03_vehicle_parts_schema.sql"
|
||||
|
||||
sudo -u postgres pg_dump \
|
||||
--schema-only \
|
||||
--no-owner \
|
||||
--no-privileges \
|
||||
--table=vehicle_parts \
|
||||
"$MASTER_DB" > "$output"
|
||||
|
||||
local size=$(du -h "$output" | cut -f1)
|
||||
ok "vehicle_parts schema: ${size}"
|
||||
}
|
||||
|
||||
# ─── Backup all tenant databases ───────────────────────────────────────────
|
||||
backup_tenants() {
|
||||
info "Discovering tenant databases..."
|
||||
|
||||
local tenants_file="${BACKUP_PATH}/_tenants.txt"
|
||||
|
||||
sudo -u postgres psql "$MASTER_DB" -Atc "
|
||||
SELECT db_name FROM tenants WHERE is_active = true ORDER BY id;
|
||||
" > "$tenants_file"
|
||||
|
||||
local tenant_count=$(wc -l < "$tenants_file")
|
||||
if [[ "$tenant_count" -eq 0 ]]; then
|
||||
warn "No active tenants found."
|
||||
rm -f "$tenants_file"
|
||||
return
|
||||
fi
|
||||
|
||||
info "Found ${tenant_count} active tenant(s). Backing up..."
|
||||
|
||||
while IFS= read -r db_name; do
|
||||
[[ -z "$db_name" ]] && continue
|
||||
|
||||
# Sanitize filename
|
||||
local safe_name=$(echo "$db_name" | tr -cd 'a-z0-9_')
|
||||
local output="${BACKUP_PATH}/tenant_${safe_name}.sql"
|
||||
|
||||
info " → Backing up ${db_name}..."
|
||||
|
||||
if sudo -u postgres psql -l | grep -q "^ ${db_name} "; then
|
||||
sudo -u postgres pg_dump \
|
||||
--no-owner \
|
||||
--no-privileges \
|
||||
"$db_name" > "$output"
|
||||
|
||||
gzip -f "$output"
|
||||
local size=$(du -h "${output}.gz" | cut -f1)
|
||||
ok " → ${db_name}: ${size}"
|
||||
else
|
||||
warn " → ${db_name}: database not found, skipping"
|
||||
fi
|
||||
done < "$tenants_file"
|
||||
|
||||
rm -f "$tenants_file"
|
||||
}
|
||||
|
||||
# ─── Create manifest ───────────────────────────────────────────────────────
|
||||
create_manifest() {
|
||||
local manifest="${BACKUP_PATH}/MANIFEST.txt"
|
||||
|
||||
cat > "$manifest" << EOF
|
||||
Nexus Autoparts — Selective Backup
|
||||
Generated: $(date '+%Y-%m-%d %H:%M:%S')
|
||||
Hostname: $(hostname)
|
||||
|
||||
CONTENTS:
|
||||
---------
|
||||
01_master_schema.sql — Database structure (all tables, indexes, constraints)
|
||||
02_master_data.sql.gz — Data for all tables EXCEPT vehicle_parts
|
||||
03_vehicle_parts_schema.sql — Structure of vehicle_parts only (no data)
|
||||
tenant_*.sql.gz — Full backup of each active tenant database
|
||||
|
||||
RESTORE INSTRUCTIONS:
|
||||
---------------------
|
||||
1. Create empty database:
|
||||
createdb nexus_autoparts
|
||||
|
||||
2. Restore schema:
|
||||
psql nexus_autoparts < 01_master_schema.sql
|
||||
|
||||
3. Restore data:
|
||||
gunzip -c 02_master_data.sql.gz | psql nexus_autoparts
|
||||
|
||||
4. Restore vehicle_parts structure (empty):
|
||||
psql nexus_autoparts < 03_vehicle_parts_schema.sql
|
||||
|
||||
5. Restore tenants:
|
||||
createdb tenant_name
|
||||
gunzip -c tenant_name.sql.gz | psql tenant_name
|
||||
|
||||
RE-IMPORT TECDOC (optional):
|
||||
----------------------------
|
||||
To reload vehicle_parts data later:
|
||||
python3 scripts/import_tecdoc.py download
|
||||
python3 scripts/import_tecdoc.py import
|
||||
EOF
|
||||
|
||||
ok "Manifest created"
|
||||
}
|
||||
|
||||
# ─── Compress final archive ────────────────────────────────────────────────
|
||||
create_archive() {
|
||||
info "Creating final archive..."
|
||||
|
||||
local archive="${BACKUP_DIR}/${BACKUP_NAME}.tar.gz"
|
||||
|
||||
cd "$BACKUP_DIR"
|
||||
tar -czf "$archive" "$BACKUP_NAME"
|
||||
|
||||
local archive_size=$(du -h "$archive" | cut -f1)
|
||||
local unpacked_size=$(du -sh "$BACKUP_PATH" | cut -f1)
|
||||
|
||||
ok "Archive created: ${archive}"
|
||||
echo ""
|
||||
echo -e " ${BOLD}Compressed:${NC} ${archive_size}"
|
||||
echo -e " ${BOLD}Unpacked:${NC} ${unpacked_size}"
|
||||
echo ""
|
||||
|
||||
# Remove temp directory (keep archive)
|
||||
rm -rf "$BACKUP_PATH"
|
||||
}
|
||||
|
||||
# ─── Cleanup old backups ───────────────────────────────────────────────────
|
||||
cleanup_old_backups() {
|
||||
info "Cleaning up backups older than ${RETENTION_DAYS} days..."
|
||||
|
||||
local deleted=0
|
||||
while IFS= read -r file; do
|
||||
rm -f "$file"
|
||||
((deleted++))
|
||||
done < <(find "$BACKUP_DIR" -name "nexus_backup_*.tar.gz" -mtime +$RETENTION_DAYS)
|
||||
|
||||
if [[ "$deleted" -gt 0 ]]; then
|
||||
ok "Deleted ${deleted} old backup(s)"
|
||||
else
|
||||
info "No old backups to delete"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Main ──────────────────────────────────────────────────────────────────
|
||||
main() {
|
||||
echo ""
|
||||
echo -e "${BOLD}${CYAN}"
|
||||
echo " ========================================"
|
||||
echo " Nexus Autoparts — Selective Backup"
|
||||
echo " ========================================"
|
||||
echo -e "${NC}"
|
||||
echo ""
|
||||
|
||||
check_prerequisites
|
||||
backup_master_schema
|
||||
backup_master_data
|
||||
backup_vehicle_parts_schema
|
||||
backup_tenants
|
||||
create_manifest
|
||||
create_archive
|
||||
cleanup_old_backups
|
||||
|
||||
echo -e "${BOLD}${GREEN}"
|
||||
echo " Backup completed successfully!"
|
||||
echo -e "${NC}"
|
||||
echo " Location: ${BACKUP_DIR}/nexus_backup_${TIMESTAMP}.tar.gz"
|
||||
echo ""
|
||||
}
|
||||
|
||||
main "$@"
|
||||
222
scripts/health_check.py
Executable file
222
scripts/health_check.py
Executable file
@@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Nexus Autoparts — Post-Installation Health Check
|
||||
|
||||
Verifies that all components are running correctly after installation.
|
||||
Usage: python3 scripts/health_check.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import requests
|
||||
import psycopg2
|
||||
import redis
|
||||
|
||||
# Ensure we can import from project root
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
def info(msg):
|
||||
print(f"[INFO] {msg}")
|
||||
|
||||
|
||||
def ok(msg):
|
||||
print(f"[OK] {msg}")
|
||||
|
||||
|
||||
def fail(msg):
|
||||
print(f"[FAIL] {msg}")
|
||||
return False
|
||||
|
||||
|
||||
def check_postgresql():
|
||||
"""Verify PostgreSQL is running and accessible."""
|
||||
info("Checking PostgreSQL...")
|
||||
try:
|
||||
conn = psycopg2.connect("postgresql://nexus@localhost/nexus_autoparts")
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT version()")
|
||||
version = cur.fetchone()[0]
|
||||
cur.close()
|
||||
conn.close()
|
||||
ok(f"PostgreSQL running: {version.split()[0]} {version.split()[1]}")
|
||||
return True
|
||||
except Exception as e:
|
||||
return fail(f"PostgreSQL connection failed: {e}")
|
||||
|
||||
|
||||
def check_master_db():
|
||||
"""Verify master DB has required tables."""
|
||||
info("Checking master database schema...")
|
||||
try:
|
||||
conn = psycopg2.connect("postgresql://nexus@localhost/nexus_autoparts")
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name IN ('tenants', 'brands', 'models', 'years', 'part_categories')
|
||||
""")
|
||||
tables = {r[0] for r in cur.fetchall()}
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
required = {'tenants', 'brands', 'models', 'years', 'part_categories'}
|
||||
missing = required - tables
|
||||
if missing:
|
||||
return fail(f"Missing master tables: {missing}")
|
||||
ok("Master database schema is complete")
|
||||
return True
|
||||
except Exception as e:
|
||||
return fail(f"Master DB check failed: {e}")
|
||||
|
||||
|
||||
def check_tenant_template():
|
||||
"""Verify tenant_template database exists."""
|
||||
info("Checking tenant_template database...")
|
||||
try:
|
||||
conn = psycopg2.connect("postgresql://nexus@localhost/tenant_template")
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT 1 FROM pg_tables WHERE tablename = 'sales'")
|
||||
if not cur.fetchone():
|
||||
return fail("tenant_template missing 'sales' table")
|
||||
cur.close()
|
||||
conn.close()
|
||||
ok("tenant_template database exists and has core tables")
|
||||
return True
|
||||
except Exception as e:
|
||||
return fail(f"tenant_template check failed: {e}")
|
||||
|
||||
|
||||
def check_first_tenant():
|
||||
"""Verify at least one tenant exists."""
|
||||
info("Checking first tenant...")
|
||||
try:
|
||||
conn = psycopg2.connect("postgresql://nexus@localhost/nexus_autoparts")
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT COUNT(*) FROM tenants WHERE is_active = true")
|
||||
count = cur.fetchone()[0]
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
if count == 0:
|
||||
return fail("No active tenants found")
|
||||
ok(f"Found {count} active tenant(s)")
|
||||
return True
|
||||
except Exception as e:
|
||||
return fail(f"Tenant check failed: {e}")
|
||||
|
||||
|
||||
def check_pos_health():
|
||||
"""Verify POS health endpoint responds."""
|
||||
info("Checking POS health endpoint...")
|
||||
try:
|
||||
resp = requests.get("http://localhost:5001/pos/health", timeout=5)
|
||||
if resp.status_code == 200 and resp.json().get("status") == "ok":
|
||||
ok("POS health endpoint is responding")
|
||||
return True
|
||||
return fail(f"POS health returned: {resp.status_code} {resp.text}")
|
||||
except requests.exceptions.ConnectionError:
|
||||
return fail("POS not running on port 5001")
|
||||
except Exception as e:
|
||||
return fail(f"POS health check failed: {e}")
|
||||
|
||||
|
||||
def check_redis():
|
||||
"""Verify Redis is running and accessible."""
|
||||
info("Checking Redis...")
|
||||
try:
|
||||
r = redis.from_url(
|
||||
os.environ.get('REDIS_URL', 'redis://localhost:6379/0'),
|
||||
decode_responses=True
|
||||
)
|
||||
if r.ping():
|
||||
info = r.info('server')
|
||||
ok(f"Redis {info.get('redis_version', '?')} running")
|
||||
return True
|
||||
return fail("Redis PING returned False")
|
||||
except Exception as e:
|
||||
return fail(f"Redis connection failed: {e}")
|
||||
|
||||
|
||||
def check_meilisearch():
|
||||
"""Verify Meilisearch is running."""
|
||||
info("Checking Meilisearch...")
|
||||
try:
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'pos'))
|
||||
from services.meili_search import health_check
|
||||
if health_check():
|
||||
ok("Meilisearch running")
|
||||
return True
|
||||
return fail("Meilisearch health check failed")
|
||||
except Exception as e:
|
||||
return fail(f"Meilisearch connection failed: {e}")
|
||||
|
||||
|
||||
def check_metabase():
|
||||
"""Verify Metabase is running."""
|
||||
info("Checking Metabase...")
|
||||
try:
|
||||
import requests
|
||||
url = os.environ.get('METABASE_URL', 'http://localhost:3000')
|
||||
r = requests.get(f"{url}/api/health", timeout=5)
|
||||
if r.status_code == 200 and r.json().get('status') == 'ok':
|
||||
ok("Metabase running")
|
||||
return True
|
||||
return fail(f"Metabase returned: {r.status_code}")
|
||||
except Exception as e:
|
||||
return fail(f"Metabase connection failed: {e}")
|
||||
|
||||
|
||||
def check_web_health():
|
||||
"""Verify web/dashboard responds."""
|
||||
info("Checking web dashboard...")
|
||||
try:
|
||||
resp = requests.get("http://localhost:5000/", timeout=5)
|
||||
if resp.status_code == 200:
|
||||
ok("Web dashboard is responding")
|
||||
return True
|
||||
return fail(f"Web returned status: {resp.status_code}")
|
||||
except requests.exceptions.ConnectionError:
|
||||
return fail("Web server not running on port 5000")
|
||||
except Exception as e:
|
||||
return fail(f"Web check failed: {e}")
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print(" Nexus Autoparts — Health Check")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
results = []
|
||||
results.append(("PostgreSQL", check_postgresql()))
|
||||
results.append(("Redis Cache", check_redis()))
|
||||
results.append(("Meilisearch", check_meilisearch()))
|
||||
results.append(("Metabase", check_metabase()))
|
||||
results.append(("Master DB Schema", check_master_db()))
|
||||
results.append(("Tenant Template", check_tenant_template()))
|
||||
results.append(("First Tenant", check_first_tenant()))
|
||||
results.append(("POS Health", check_pos_health()))
|
||||
results.append(("Web Dashboard", check_web_health()))
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
passed = sum(1 for _, r in results if r)
|
||||
total = len(results)
|
||||
print(f" Results: {passed}/{total} checks passed")
|
||||
print("=" * 60)
|
||||
|
||||
if passed < total:
|
||||
print()
|
||||
print("Failed checks:")
|
||||
for name, result in results:
|
||||
if not result:
|
||||
print(f" - {name}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print()
|
||||
print("All systems operational!")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
365
scripts/setup_metabase.py
Normal file
365
scripts/setup_metabase.py
Normal file
@@ -0,0 +1,365 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Automated Metabase setup for Nexus Autoparts KPIs.
|
||||
|
||||
Performs first-time setup if needed, then creates:
|
||||
- PostgreSQL database connection (master + tenant template)
|
||||
- Collection "Nexus KPIs"
|
||||
- Pre-built questions (cards) for common refaccionaria metrics
|
||||
- Dashboard grouping those cards
|
||||
|
||||
Usage:
|
||||
export METABASE_URL=http://localhost:3000
|
||||
export METABASE_ADMIN_EMAIL=admin@nexus.local
|
||||
export METABASE_ADMIN_PASS=changeme123
|
||||
export MASTER_DB_URL=postgresql://nexus:pass@localhost/nexus_autoparts
|
||||
python3 scripts/setup_metabase.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import requests
|
||||
|
||||
METABASE_URL = os.environ.get('METABASE_URL', 'http://localhost:3000').rstrip('/')
|
||||
ADMIN_EMAIL = os.environ.get('METABASE_ADMIN_EMAIL', 'admin@nexus.local')
|
||||
ADMIN_PASS = os.environ.get('METABASE_ADMIN_PASS', '')
|
||||
MASTER_DB_URL = os.environ.get('MASTER_DB_URL', '')
|
||||
|
||||
DB_CONFIG_PATH = os.path.expanduser('~/.nexus_metabase_config.json')
|
||||
|
||||
|
||||
def _get(url, session=None):
|
||||
headers = {}
|
||||
if session:
|
||||
headers['X-Metabase-Session'] = session
|
||||
r = requests.get(url, headers=headers)
|
||||
return r
|
||||
|
||||
|
||||
def _post(url, data, session=None):
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
if session:
|
||||
headers['X-Metabase-Session'] = session
|
||||
r = requests.post(url, headers=headers, json=data)
|
||||
return r
|
||||
|
||||
|
||||
def get_setup_token():
|
||||
r = _get(f"{METABASE_URL}/api/session/properties")
|
||||
return r.json().get('setup-token')
|
||||
|
||||
|
||||
def do_setup(token):
|
||||
"""Create first admin user and initial DB connection."""
|
||||
if not ADMIN_PASS:
|
||||
print("ERROR: METABASE_ADMIN_PASS is required for first-time setup.")
|
||||
sys.exit(1)
|
||||
|
||||
# Parse DB connection
|
||||
db_name = 'nexus_autoparts'
|
||||
db_user = 'nexus'
|
||||
db_pass = ''
|
||||
if MASTER_DB_URL:
|
||||
# postgresql://user:pass@host/db
|
||||
rest = MASTER_DB_URL.replace('postgresql://', '')
|
||||
if '@' in rest:
|
||||
auth, host_db = rest.split('@', 1)
|
||||
if ':' in auth:
|
||||
db_user, db_pass = auth.split(':', 1)
|
||||
else:
|
||||
db_user = auth
|
||||
if '/' in host_db:
|
||||
host_port, db_name = host_db.split('/', 1)
|
||||
else:
|
||||
host_port = host_db
|
||||
host = host_port.split(':')[0]
|
||||
else:
|
||||
host = 'localhost'
|
||||
else:
|
||||
host = 'host.docker.internal'
|
||||
|
||||
payload = {
|
||||
'token': token,
|
||||
'user': {
|
||||
'first_name': 'Admin',
|
||||
'last_name': 'Nexus',
|
||||
'email': ADMIN_EMAIL,
|
||||
'site_name': 'Nexus Autoparts',
|
||||
'password': ADMIN_PASS,
|
||||
},
|
||||
'prefs': {
|
||||
'site_name': 'Nexus Autoparts',
|
||||
'site_locale': 'es',
|
||||
'allow_tracking': False,
|
||||
},
|
||||
'database': {
|
||||
'engine': 'postgres',
|
||||
'name': 'Nexus Master DB',
|
||||
'details': {
|
||||
'host': host,
|
||||
'port': 5432,
|
||||
'dbname': db_name,
|
||||
'user': db_user,
|
||||
'password': db_pass,
|
||||
'ssl': False,
|
||||
'tunnel-enabled': False,
|
||||
},
|
||||
'auto_run_queries': True,
|
||||
'is_full_sync': True,
|
||||
'is_on_demand': False,
|
||||
}
|
||||
}
|
||||
|
||||
r = _post(f"{METABASE_URL}/api/setup", payload)
|
||||
if r.status_code not in (200, 201):
|
||||
print(f"Setup failed: {r.status_code} {r.text}")
|
||||
sys.exit(1)
|
||||
|
||||
data = r.json()
|
||||
session_id = data.get('id')
|
||||
print(f"Setup complete. Admin user created: {ADMIN_EMAIL}")
|
||||
return session_id
|
||||
|
||||
|
||||
def login():
|
||||
"""Login existing user."""
|
||||
r = _post(f"{METABASE_URL}/api/session", {
|
||||
'username': ADMIN_EMAIL,
|
||||
'password': ADMIN_PASS,
|
||||
})
|
||||
if r.status_code != 200:
|
||||
print(f"Login failed: {r.status_code} {r.text}")
|
||||
return None
|
||||
return r.json().get('id')
|
||||
|
||||
|
||||
def get_or_create_collection(session, name="Nexus KPIs"):
|
||||
"""Get existing collection or create new."""
|
||||
r = _get(f"{METABASE_URL}/api/collection", session)
|
||||
for c in r.json():
|
||||
if c.get('name') == name:
|
||||
return c['id']
|
||||
|
||||
r = _post(f"{METABASE_URL}/api/collection", {
|
||||
'name': name,
|
||||
'color': '#509EE3',
|
||||
'description': 'Dashboards y métricas para Nexus Autoparts'
|
||||
}, session)
|
||||
return r.json()['id']
|
||||
|
||||
|
||||
def create_question(session, collection_id, name, sql, display='table', visualization_settings=None):
|
||||
"""Create a native SQL question (card)."""
|
||||
payload = {
|
||||
'name': name,
|
||||
'dataset_query': {
|
||||
'type': 'native',
|
||||
'native': {
|
||||
'query': sql,
|
||||
'template-tags': {},
|
||||
},
|
||||
'database': None, # Will be set from first available DB
|
||||
},
|
||||
'display': display,
|
||||
'collection_id': collection_id,
|
||||
'visualization_settings': visualization_settings or {},
|
||||
}
|
||||
# Find first available database
|
||||
dbs = _get(f"{METABASE_URL}/api/database", session).json()
|
||||
if 'data' in dbs and dbs['data']:
|
||||
payload['dataset_query']['database'] = dbs['data'][0]['id']
|
||||
else:
|
||||
print("WARNING: No databases found in Metabase. Skipping question creation.")
|
||||
return None
|
||||
|
||||
r = _post(f"{METABASE_URL}/api/card", payload, session)
|
||||
if r.status_code in (200, 201):
|
||||
return r.json()['id']
|
||||
print(f"Card creation failed ({name}): {r.status_code} {r.text[:200]}")
|
||||
return None
|
||||
|
||||
|
||||
def create_dashboard(session, collection_id, name):
|
||||
"""Create a dashboard."""
|
||||
r = _post(f"{METABASE_URL}/api/dashboard", {
|
||||
'name': name,
|
||||
'collection_id': collection_id,
|
||||
'description': 'KPIs principales de la refaccionaria',
|
||||
}, session)
|
||||
if r.status_code in (200, 201):
|
||||
return r.json()['id']
|
||||
print(f"Dashboard creation failed: {r.status_code} {r.text[:200]}")
|
||||
return None
|
||||
|
||||
|
||||
def add_card_to_dashboard(session, dashboard_id, card_id, row, col, size_x=6, size_y=4):
|
||||
"""Add a card to a dashboard grid."""
|
||||
payload = {
|
||||
'cardId': card_id,
|
||||
'row': row,
|
||||
'col': col,
|
||||
'sizeX': size_x,
|
||||
'sizeY': size_y,
|
||||
}
|
||||
r = _post(f"{METABASE_URL}/api/dashboard/{dashboard_id}/cards", payload, session)
|
||||
return r.status_code in (200, 201)
|
||||
|
||||
|
||||
def main():
|
||||
print("Nexus Autoparts — Metabase Setup")
|
||||
print("=" * 50)
|
||||
|
||||
# Verify Metabase is reachable
|
||||
try:
|
||||
health = requests.get(f"{METABASE_URL}/api/health").json()
|
||||
if health.get('status') != 'ok':
|
||||
print("ERROR: Metabase is not ready.")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"ERROR: Cannot reach Metabase: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Determine if first-time setup is needed
|
||||
token = get_setup_token()
|
||||
session = None
|
||||
if token:
|
||||
print("First-time setup detected...")
|
||||
session = do_setup(token)
|
||||
else:
|
||||
print("Metabase already set up. Logging in...")
|
||||
if not ADMIN_PASS:
|
||||
print("ERROR: METABASE_ADMIN_PASS required to login.")
|
||||
sys.exit(1)
|
||||
session = login()
|
||||
|
||||
if not session:
|
||||
print("ERROR: Could not obtain Metabase session.")
|
||||
sys.exit(1)
|
||||
|
||||
# Save config
|
||||
config = {
|
||||
'metabase_url': METABASE_URL,
|
||||
'admin_email': ADMIN_EMAIL,
|
||||
'session_id': session,
|
||||
}
|
||||
with open(DB_CONFIG_PATH, 'w') as f:
|
||||
json.dump(config, f)
|
||||
os.chmod(DB_CONFIG_PATH, 0o600)
|
||||
|
||||
# Create collection
|
||||
coll_id = get_or_create_collection(session)
|
||||
print(f"Collection ID: {coll_id}")
|
||||
|
||||
# Create questions
|
||||
questions = []
|
||||
|
||||
q1 = create_question(session, coll_id,
|
||||
"Ventas por día (últimos 30 días)",
|
||||
"""
|
||||
SELECT DATE(created_at) as fecha, COUNT(*) as ventas, SUM(total) as total
|
||||
FROM sales
|
||||
WHERE status = 'completed'
|
||||
AND created_at >= CURRENT_DATE - INTERVAL '30 days'
|
||||
GROUP BY fecha
|
||||
ORDER BY fecha DESC;
|
||||
""",
|
||||
display='line',
|
||||
visualization_settings={"graph.dimensions":["fecha"],"graph.metrics":["ventas","total"]}
|
||||
)
|
||||
if q1: questions.append((q1, 0, 0, 6, 4))
|
||||
|
||||
q2 = create_question(session, coll_id,
|
||||
"Top 10 productos vendidos",
|
||||
"""
|
||||
SELECT si.part_number, si.name, SUM(si.quantity) as cantidad, SUM(si.subtotal) as revenue
|
||||
FROM sale_items si
|
||||
JOIN sales s ON si.sale_id = s.id
|
||||
WHERE s.status = 'completed'
|
||||
GROUP BY si.part_number, si.name
|
||||
ORDER BY cantidad DESC
|
||||
LIMIT 10;
|
||||
""",
|
||||
display='bar'
|
||||
)
|
||||
if q2: questions.append((q2, 0, 6, 6, 4))
|
||||
|
||||
q3 = create_question(session, coll_id,
|
||||
"Stock bajo (reorder alerts abiertas)",
|
||||
"""
|
||||
SELECT i.part_number, i.name, ra.stock_at_alert, ra.threshold, b.name as branch
|
||||
FROM reorder_alerts ra
|
||||
JOIN inventory i ON ra.inventory_id = i.id
|
||||
LEFT JOIN branches b ON ra.branch_id = b.id
|
||||
WHERE ra.status = 'open'
|
||||
ORDER BY ra.stock_at_alert ASC;
|
||||
"""
|
||||
)
|
||||
if q3: questions.append((q3, 4, 0, 6, 4))
|
||||
|
||||
q4 = create_question(session, coll_id,
|
||||
"Ventas por sucursal (este mes)",
|
||||
"""
|
||||
SELECT b.name as sucursal, COUNT(*) as ventas, SUM(s.total) as total
|
||||
FROM sales s
|
||||
LEFT JOIN branches b ON s.branch_id = b.id
|
||||
WHERE s.status = 'completed'
|
||||
AND s.created_at >= DATE_TRUNC('month', CURRENT_DATE)
|
||||
GROUP BY sucursal
|
||||
ORDER BY total DESC;
|
||||
""",
|
||||
display='pie'
|
||||
)
|
||||
if q4: questions.append((q4, 4, 6, 6, 4))
|
||||
|
||||
q5 = create_question(session, coll_id,
|
||||
"Clientes con más compras",
|
||||
"""
|
||||
SELECT c.name, COUNT(*) as compras, SUM(s.total) as total
|
||||
FROM sales s
|
||||
JOIN customers c ON s.customer_id = c.id
|
||||
WHERE s.status = 'completed'
|
||||
GROUP BY c.name
|
||||
ORDER BY total DESC
|
||||
LIMIT 10;
|
||||
"""
|
||||
)
|
||||
if q5: questions.append((q5, 8, 0, 6, 4))
|
||||
|
||||
q6 = create_question(session, coll_id,
|
||||
"Margen de ganancia por producto",
|
||||
"""
|
||||
SELECT si.name,
|
||||
SUM(si.unit_cost * si.quantity) as costo_total,
|
||||
SUM(si.subtotal) as venta_total,
|
||||
ROUND(((SUM(si.subtotal) - SUM(si.unit_cost * si.quantity)) / NULLIF(SUM(si.subtotal), 0)) * 100, 2) as margen_pct
|
||||
FROM sale_items si
|
||||
JOIN sales s ON si.sale_id = s.id
|
||||
WHERE s.status = 'completed'
|
||||
GROUP BY si.name
|
||||
HAVING SUM(si.subtotal) > 0
|
||||
ORDER BY margen_pct DESC
|
||||
LIMIT 10;
|
||||
"""
|
||||
)
|
||||
if q6: questions.append((q6, 8, 6, 6, 4))
|
||||
|
||||
# Create dashboard
|
||||
if questions:
|
||||
dash_id = create_dashboard(session, coll_id, "Nexus KPIs — Panel Principal")
|
||||
if dash_id:
|
||||
for card_id, row, col, sx, sy in questions:
|
||||
add_card_to_dashboard(session, dash_id, card_id, row, col, sx, sy)
|
||||
print(f"Dashboard created: {METABASE_URL}/dashboard/{dash_id}")
|
||||
else:
|
||||
print("Dashboard creation skipped.")
|
||||
else:
|
||||
print("No questions created (no database connected yet).")
|
||||
|
||||
print("\nDone. Access Metabase at:", METABASE_URL)
|
||||
print(f"Admin email: {ADMIN_EMAIL}")
|
||||
print(f"Config saved to: {DB_CONFIG_PATH}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
92
scripts/sync_meilisearch.py
Normal file
92
scripts/sync_meilisearch.py
Normal file
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bulk-sync parts from PostgreSQL master DB into Meilisearch.
|
||||
|
||||
Usage:
|
||||
python3 scripts/sync_meilisearch.py [--clear]
|
||||
|
||||
Requires environment variables:
|
||||
MASTER_DB_URL=postgresql://user:pass@localhost/nexus_autoparts
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import argparse
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'pos'))
|
||||
|
||||
import psycopg2
|
||||
from services.meili_search import ensure_index, index_parts_bulk, clear_index, health_check
|
||||
|
||||
|
||||
def fetch_parts(conn, batch_size=5000):
|
||||
"""Yield parts from PostgreSQL as dicts."""
|
||||
cur = conn.cursor(name='parts_cursor')
|
||||
cur.execute("""
|
||||
SELECT id_part, oem_part_number, name_part, name_es,
|
||||
description, description_es, image_url, group_id
|
||||
FROM parts
|
||||
ORDER BY id_part
|
||||
""")
|
||||
while True:
|
||||
rows = cur.fetchmany(batch_size)
|
||||
if not rows:
|
||||
break
|
||||
for row in rows:
|
||||
yield {
|
||||
'id_part': row[0],
|
||||
'oem_part_number': row[1],
|
||||
'name_part': row[2],
|
||||
'name_es': row[3] or row[2],
|
||||
'description': row[4] or '',
|
||||
'description_es': row[5] or '',
|
||||
'image_url': row[6] or '',
|
||||
'group_id': row[7],
|
||||
}
|
||||
cur.close()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Sync parts to Meilisearch')
|
||||
parser.add_argument('--clear', action='store_true', help='Clear index before sync')
|
||||
parser.add_argument('--batch-size', type=int, default=5000, help='PostgreSQL fetch batch size')
|
||||
parser.add_argument('--index-batch', type=int, default=1000, help='Meilisearch upload batch size')
|
||||
args = parser.parse_args()
|
||||
|
||||
print("Meilisearch Sync")
|
||||
print("=" * 50)
|
||||
|
||||
if not health_check():
|
||||
print("ERROR: Meilisearch is not reachable.")
|
||||
print(f" URL: {os.environ.get('MEILI_URL', 'http://localhost:7700')}")
|
||||
sys.exit(1)
|
||||
|
||||
master_db_url = os.environ.get('MASTER_DB_URL')
|
||||
if not master_db_url:
|
||||
print("ERROR: MASTER_DB_URL environment variable is required.")
|
||||
sys.exit(1)
|
||||
|
||||
ensure_index()
|
||||
|
||||
if args.clear:
|
||||
print("Clearing existing index...")
|
||||
clear_index()
|
||||
|
||||
print(f"Connecting to PostgreSQL...")
|
||||
conn = psycopg2.connect(master_db_url)
|
||||
|
||||
# Count total
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT COUNT(*) FROM parts")
|
||||
total_rows = cur.fetchone()[0]
|
||||
cur.close()
|
||||
print(f"Parts to index: {total_rows}")
|
||||
|
||||
print("Indexing...")
|
||||
indexed = index_parts_bulk(fetch_parts(conn, args.batch_size), batch_size=args.index_batch)
|
||||
conn.close()
|
||||
|
||||
print(f"Done. Indexed {indexed} documents.")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
242
scripts/test_performance_fixes.py
Executable file
242
scripts/test_performance_fixes.py
Executable file
@@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test script to verify N+1 fixes and race condition protections.
|
||||
|
||||
This script tests:
|
||||
1. Batch inventory fetch in _enrich_items (no N+1)
|
||||
2. Batch stock preload in process_sale
|
||||
3. FOR UPDATE locks are applied correctly
|
||||
4. executemany for sale_items works
|
||||
5. Basic sale creation still functions
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'pos'))
|
||||
|
||||
# Need env vars for config
|
||||
os.environ.setdefault('MASTER_DB_URL', 'postgresql://nexus:nexus_autoparts_2026@localhost/nexus_autoparts')
|
||||
os.environ.setdefault('TENANT_DB_URL_TEMPLATE', 'postgresql://nexus:nexus_autoparts_2026@localhost/{db_name}')
|
||||
os.environ.setdefault('POS_JWT_SECRET', 'test-secret-for-validation-only')
|
||||
os.environ.setdefault('DATABASE_URL', os.environ['MASTER_DB_URL'])
|
||||
|
||||
from tenant_db import get_tenant_conn_by_dbname
|
||||
from services.pos_engine import process_sale, calculate_totals
|
||||
from services.inventory_engine import get_stock, get_stock_bulk, record_sale
|
||||
from blueprints.pos_bp import _enrich_items
|
||||
import psycopg2
|
||||
|
||||
|
||||
def test_batch_inventory_fetch():
|
||||
"""Test that _enrich_items fetches all items in batch (no N+1)."""
|
||||
print("\n[TEST] Batch inventory fetch in _enrich_items...")
|
||||
|
||||
conn = get_tenant_conn_by_dbname('tenant_refaccionaria_demo')
|
||||
cur = conn.cursor()
|
||||
|
||||
# Get some inventory IDs
|
||||
cur.execute("SELECT id FROM inventory WHERE is_active = true LIMIT 3")
|
||||
inv_ids = [r[0] for r in cur.fetchall()]
|
||||
|
||||
if len(inv_ids) < 2:
|
||||
print(" SKIP: Need at least 2 inventory items")
|
||||
cur.close()
|
||||
conn.close()
|
||||
return True
|
||||
|
||||
items = [{'inventory_id': iid, 'quantity': 1} for iid in inv_ids]
|
||||
|
||||
# Time the batch fetch
|
||||
start = time.time()
|
||||
enriched = _enrich_items(cur, items)
|
||||
elapsed = time.time() - start
|
||||
|
||||
assert len(enriched) == len(inv_ids), "Not all items were enriched"
|
||||
assert all('part_number' in e for e in enriched), "Missing part_number in enriched items"
|
||||
|
||||
print(f" OK: Enriched {len(enriched)} items in {elapsed:.3f}s (batch fetch)")
|
||||
cur.close()
|
||||
conn.close()
|
||||
return True
|
||||
|
||||
|
||||
def test_batch_stock_preload():
|
||||
"""Test that get_stock_bulk fetches all stock in one query."""
|
||||
print("\n[TEST] Batch stock preload with get_stock_bulk...")
|
||||
|
||||
conn = get_tenant_conn_by_dbname('tenant_refaccionaria_demo')
|
||||
|
||||
start = time.time()
|
||||
stock_map = get_stock_bulk(conn)
|
||||
elapsed = time.time() - start
|
||||
|
||||
print(f" OK: Fetched stock for {len(stock_map)} items in {elapsed:.3f}s (single query)")
|
||||
|
||||
conn.close()
|
||||
return True
|
||||
|
||||
|
||||
def test_sale_creation():
|
||||
"""Test that a basic sale can be created with the optimized code."""
|
||||
print("\n[TEST] Sale creation with optimized engine...")
|
||||
|
||||
conn = get_tenant_conn_by_dbname('tenant_refaccionaria_demo')
|
||||
cur = conn.cursor()
|
||||
|
||||
# Get an inventory item and an employee
|
||||
cur.execute("SELECT id, branch_id FROM inventory WHERE is_active = true LIMIT 1")
|
||||
inv_row = cur.fetchone()
|
||||
if not inv_row:
|
||||
print(" SKIP: No inventory items available")
|
||||
cur.close()
|
||||
conn.close()
|
||||
return True
|
||||
|
||||
inv_id = inv_row[0]
|
||||
branch_id_val = inv_row[1]
|
||||
|
||||
cur.execute("SELECT id FROM employees WHERE role = 'owner' LIMIT 1")
|
||||
emp_row = cur.fetchone()
|
||||
employee_id = emp_row[0] if emp_row else 1
|
||||
|
||||
# Get or create an open cash register
|
||||
cur.execute("SELECT id FROM cash_registers WHERE status = 'open' AND branch_id = %s LIMIT 1", (branch_id_val,))
|
||||
reg_row = cur.fetchone()
|
||||
if not reg_row:
|
||||
# Create one
|
||||
cur.execute("INSERT INTO cash_registers (branch_id, employee_id, register_number, opening_amount, status) VALUES (%s, %s, %s, %s, 'open') RETURNING id",
|
||||
(branch_id_val, employee_id, 1, 1000.00))
|
||||
register_id = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
else:
|
||||
register_id = reg_row[0]
|
||||
|
||||
cur.close()
|
||||
|
||||
# Create minimal Flask request context for g object
|
||||
from flask import Flask, g
|
||||
app = Flask('test')
|
||||
|
||||
with app.test_request_context():
|
||||
g.branch_id = branch_id_val
|
||||
g.employee_id = employee_id
|
||||
g.employee_role = 'owner'
|
||||
g.device_id = 'test-device'
|
||||
g.max_discount_pct = 100
|
||||
g.permissions = set()
|
||||
|
||||
sale_data = {
|
||||
'items': [{
|
||||
'inventory_id': inv_id,
|
||||
'quantity': 1,
|
||||
'unit_price': 100.00,
|
||||
'discount_pct': 0,
|
||||
'tax_rate': 0.16
|
||||
}],
|
||||
'customer_id': None,
|
||||
'payment_method': 'efectivo',
|
||||
'sale_type': 'cash',
|
||||
'register_id': register_id,
|
||||
'amount_paid': 116.00,
|
||||
}
|
||||
|
||||
start = time.time()
|
||||
sale = process_sale(conn, sale_data)
|
||||
conn.commit()
|
||||
elapsed = time.time() - start
|
||||
|
||||
assert sale['id'] > 0, "Sale was not created"
|
||||
assert sale['total'] > 0, "Sale total is invalid"
|
||||
|
||||
print(f" OK: Created sale #{sale['id']} for ${sale['total']:.2f} in {elapsed:.3f}s")
|
||||
|
||||
# Cleanup: cancel the test sale
|
||||
from services.pos_engine import cancel_sale
|
||||
g.branch_id = branch_id_val
|
||||
g.employee_id = employee_id
|
||||
g.employee_role = 'owner'
|
||||
g.device_id = 'test-device'
|
||||
g.max_discount_pct = 100
|
||||
g.permissions = set()
|
||||
|
||||
cancel_sale(conn, sale['id'], "Test cleanup")
|
||||
conn.commit()
|
||||
print(f" OK: Cancelled test sale #{sale['id']}")
|
||||
|
||||
conn.close()
|
||||
return True
|
||||
|
||||
|
||||
def test_race_condition_locks():
|
||||
"""Verify that FOR UPDATE is present in the code (static check)."""
|
||||
print("\n[TEST] Race condition protection (FOR UPDATE locks)...")
|
||||
|
||||
engine_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'pos', 'services', 'pos_engine.py')
|
||||
with open(engine_file) as f:
|
||||
content = f.read()
|
||||
|
||||
checks = [
|
||||
('inventory FOR UPDATE', 'FOR UPDATE' in content),
|
||||
('customers FOR UPDATE', 'FOR UPDATE' in content),
|
||||
]
|
||||
|
||||
for name, result in checks:
|
||||
status = "OK" if result else "FAIL"
|
||||
print(f" {status}: {name}")
|
||||
if not result:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print(" Nexus Autoparts — Performance Fixes Validation")
|
||||
print("=" * 60)
|
||||
|
||||
results = []
|
||||
|
||||
try:
|
||||
results.append(("Batch inventory fetch", test_batch_inventory_fetch()))
|
||||
except Exception as e:
|
||||
print(f" FAIL: {e}")
|
||||
results.append(("Batch inventory fetch", False))
|
||||
|
||||
try:
|
||||
results.append(("Batch stock preload", test_batch_stock_preload()))
|
||||
except Exception as e:
|
||||
print(f" FAIL: {e}")
|
||||
results.append(("Batch stock preload", False))
|
||||
|
||||
try:
|
||||
results.append(("Race condition locks", test_race_condition_locks()))
|
||||
except Exception as e:
|
||||
print(f" FAIL: {e}")
|
||||
results.append(("Race condition locks", False))
|
||||
|
||||
try:
|
||||
results.append(("Sale creation (end-to-end)", test_sale_creation()))
|
||||
except Exception as e:
|
||||
print(f" FAIL: {e}")
|
||||
results.append(("Sale creation (end-to-end)", False))
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
passed = sum(1 for _, r in results if r)
|
||||
total = len(results)
|
||||
print(f" Results: {passed}/{total} tests passed")
|
||||
print("=" * 60)
|
||||
|
||||
if passed < total:
|
||||
print("\nFailed tests:")
|
||||
for name, result in results:
|
||||
if not result:
|
||||
print(f" - {name}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("\nAll performance fixes validated successfully!")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user