commit c3d0eedc8d10c445c7bf8f4089b76f04b1dc537c Author: Aduanasoft Date: Tue Jul 14 09:03:52 2026 -0600 chore: baseline plantilla-proyectos como base del CRM diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e3663b4 --- /dev/null +++ b/.env.example @@ -0,0 +1,113 @@ +# ================================== +# ANEXO76 - Variables de Entorno +# ================================== + +# ----- PostgreSQL App ----- +POSTGRES_APP_PASSWORD=postgres + +# ----- PostgreSQL Keycloak ----- +POSTGRES_KEYCLOAK_PASSWORD=postgres + +# ----- Keycloak Admin ----- +KEYCLOAK_ADMIN=admin +KEYCLOAK_ADMIN_PASSWORD=admin + +# ----- Keycloak Configuración ----- +KEYCLOAK_REALM=master +KEYCLOAK_CLIENT_ID=app-backend +KEYCLOAK_CLIENT_SECRET=dev-secret +KEYCLOAK_FRONTEND_CLIENT_ID=app-frontend + +# ----- Backend ----- +DEBUG=True +ENVIRONMENT=development +CORE_DB_HOST=postgres-a76 +CORE_DB_PORT=5432 +CORE_DB_NAME=anexo76_core +CORE_DB_USER=postgres +CORE_DB_PASSWORD=postgres + +# Lista de orígenes permitidos (CORS) +# Ejemplo para Hub: http://localhost:5173,http://100.78.6.108:5174,http://100.78.6.108:8001 +CORS_ORIGINS=http://localhost:5173,http://localhost:3000 + +# ----- Frontend ----- +NODE_ENV=development +VITE_API_URL=http://localhost:8000/api +INTERNAL_API_URL=http://backend:8000/api + +# ⚠️ ADVERTENCIA PRODUCCIÓN — ORIGIN, VITE_HUB_URL y APP_PUBLIC_URL +# ───────────────────────────────────────────────────────────────────── +# Docker Compose carga ESTE archivo (.env raíz) automáticamente cuando +# se ejecuta: docker compose -f docker-compose.prod.yml up +# +# Si estas variables tienen valores localhost aquí, PISARÁN los defaults +# de docker-compose.prod.yml y causarán que el login falle en producción +# (redirect_uri y KC URL apuntarán a localhost). +# +# Para dev local (docker-compose.yml): dejar localhost. +# Para producción (docker-compose.prod.yml): asegurarse que el servidor +# NO tenga este .env raíz con valores localhost, O usar: +# docker compose --env-file .env.prod -f docker-compose.prod.yml up +# +# Variables críticas para producción: +# ORIGIN=https://anexo76-dev.aduanasoft.com ← determina url.origin en SvelteKit +# VITE_HUB_URL=https://workspace.aduanasoft.com +# APP_PUBLIC_URL=https://anexo76-dev.aduanasoft.com +ORIGIN=http://localhost:5173 +VITE_HUB_URL=http://localhost:3001 +APP_PUBLIC_URL=http://localhost:5173 +VITE_KEYCLOAK_REALM=master +VITE_KEYCLOAK_URL=http://localhost:8080/kcauth +VITE_KEYCLOAK_CLIENT_ID=app-frontend + +#------ Celery / Valkey ---------- +VALKEY_URL=redis://valkey:6379/0 +PERMISSION_CACHE_ENABLED=true +PERMISSION_CACHE_TTL_SECONDS=300 + +# ----- MinIO (S3-compatible) ----- +MINIO_ROOT_USER=minioadmin +MINIO_ROOT_PASSWORD=minioadmin +MINIO_API_PORT=9100 +MINIO_CONSOLE_PORT=9101 + +# ----- Imports CSV (layouts_csv): redis | minio ----- +CSV_IMPORT_STORAGE=minio +S3_ENDPOINT_URL=http://minio:9000 +S3_ACCESS_KEY=minioadmin +S3_SECRET_KEY=minioadmin +S3_BUCKET=anexo76 +S3_REGION=us-east-1 +S3_USE_SSL=false +# Logos, certificados, help: mismo bucket. Si CSV_IMPORT_STORAGE=minio, también se usa MinIO aquí +# (use_s3_object_storage = minio CSV o S3_FILE_STORAGE=true). +S3_FILE_STORAGE=true +S3_PRESIGNED_EXPIRES_SECONDS=3600 + +COVE_FIEL_HASH_KEY= +COVE_FIEL_HASH_IV= +COVE_API_URL=https://api.vu.aduanasoft.com +COVE_API_VERIFY_SSL=False + +# ----- Sitar API ----- +SITAR_API_URL=http://api.sitar.aduanasoft.com +SITAR_API_USER=user_sitar_api +SITAR_API_PASSWORD=password123 + +# ================================== +# CONFIGURACIÓN DE SINCRONIZACIÓN +# ================================== +# Hub IP: 100.78.6.108 + +# URL del Hub para sincronización (Solo si es CLIENTE) +# Ejemplo: http://100.78.6.108:8001/api/v1/core/help-center/sync/ +CENTRAL_SERVER_URL= + +# UUID único de este cliente (Opcional, se genera uno si está vacío) + +# Token de seguridad compartido (Debe ser IDÉNTICO en Hub y Clientes) +SYNC_SECRET_TOKEN=change-this-sync-token-in-production + +# Lista de spokes (Solo si es HUB y desea retransmitir a otros - Opcional) +SPOKE_URLS="" diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..24d77a3 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,203 @@ +name: Build Producción & Push a Harbor + +on: + push: + branches: + - main + - development + workflow_dispatch: + inputs: + environment: + description: 'Desplegar dev' + required: true + default: 'development' + type: choice + options: + - development + - main + +jobs: + # Ejecuta la suite de tests del backend antes de construir imágenes. + # Si falla, no se ejecuta build (ni push ni deploy). + test: + runs-on: self-hosted + steps: + - name: Checkout código + uses: actions/checkout@v4 + + # No usamos actions/setup-python: en runners self-hosted (Debian) suele fallar + # al descargar versiones desde el manifest. Se usa python3 del sistema + venv. + # En Debian/Ubuntu hace falta el paquete python3-venv (ensurepip); si falla el venv, + # se intenta instalar con apt (root en act/containers, sudo en servidor típico). + - name: Instalar dependencias y ejecutar tests del backend + env: + TEST_DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }} + run: | + set -e + if [ -z "$TEST_DATABASE_URL" ]; then + echo "::error::Define el secret TEST_DATABASE_URL en el repo (Gitea → Ajustes → Secretos)." + echo "Ejemplo: postgresql://usuario:clave@127.0.0.1:5432/nombre_bd (host real, no el texto \"host\")" + exit 1 + fi + python3 --version + VENV="$GITHUB_WORKSPACE/.pytest-venv" + install_python3_venv() { + export DEBIAN_FRONTEND=noninteractive + if ! command -v apt-get >/dev/null 2>&1; then + echo "::error::No hay apt-get. Instala manualmente el paquete equivalente a python3-venv." + return 1 + fi + if [ "$(id -u)" -eq 0 ]; then + apt-get update -qq + apt-get install -y python3-venv + else + sudo apt-get update -qq + sudo apt-get install -y python3-venv + fi + } + if ! python3 -m venv "$VENV" 2>/dev/null; then + echo "venv falló (ensurepip no disponible). Instalando python3-venv..." + install_python3_venv + python3 -m venv "$VENV" + fi + . "$VENV/bin/activate" + python -m pip install --upgrade pip + pip install -r "$GITHUB_WORKSPACE/backend/requirements.txt" + cd "$GITHUB_WORKSPACE/backend" + export TEST_DATABASE_URL="$TEST_DATABASE_URL" + alembic upgrade head + pytest -q tests -v -ra -s + + build: + runs-on: self-hosted + needs: test + + steps: + - name: Checkout código + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Necesario para obtener el historial completo de git + + # ------------------------ + # Generar versión automática + # ------------------------ + - name: Generar versión automática + id: version + run: | + # Obtener año y mes actual + YEAR=$(date +%y) + MONTH=$(date +%m) + + # Detectar rama actual usando variables de Gitea + BRANCH="${GITHUB_REF##*/}" + + if [ "$BRANCH" = "development" ]; then + # Para development: YY.MM.1. + SHORT_HASH=$(git rev-parse --short=8 HEAD) + VERSION="${YEAR}.${MONTH}.${{ secrets.VERSION_MAYOR }}.${SHORT_HASH}" + elif [ "$BRANCH" = "main" ]; then + # Para main: YY.MM.0. + # Intentar obtener el último tag + LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + + if [ -z "$LAST_TAG" ]; then + # No hay tags, usar commit count total (primera ejecución) + COMMIT_COUNT=$(git rev-list --count HEAD) + echo "⚠️ No se encontraron tags. Usando commit count total: ${COMMIT_COUNT}" + else + # Hay tags, contar commits desde el último tag + COMMIT_COUNT=$(git rev-list ${LAST_TAG}..HEAD --count) + # Si es 0, significa que estamos en el mismo commit del tag, incrementar + if [ "$COMMIT_COUNT" -eq 0 ]; then + COMMIT_COUNT=1 + fi + echo "📌 Último tag: ${LAST_TAG}" + echo "🔢 Commits desde el último tag: ${COMMIT_COUNT}" + fi + + VERSION="${YEAR}.${MONTH}.${{ secrets.VERSION_MAYOR }}.${COMMIT_COUNT}" + else + # Fallback para otras ramas + SHORT_HASH=$(git rev-parse --short=8 HEAD) + VERSION="${YEAR}.${MONTH}.99.${SHORT_HASH}" + fi + + # Guardar versión en output para usarla en steps posteriores + echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT + echo "BRANCH=${BRANCH}" >> $GITHUB_OUTPUT + + # Mostrar en logs + echo "📦 Versión generada: ${VERSION}" + echo "🌿 Rama: ${BRANCH}" + + - name: Login a Harbor + run: | + echo '${{ secrets.HARBOR_PASSWORD }}' | docker login \ + dev.aduanasoft.com \ + -u '${{ secrets.HARBOR_USERNAME }}' \ + --password-stdin + + # ------------------------ + # Backend + # ------------------------ + - name: Build backend + run: | + docker build \ + --build-arg APP_VERSION=${{ steps.version.outputs.VERSION }} \ + -t dev.aduanasoft.com/anexo76/backend:latest \ + -f ./backend/Dockerfile \ + ./backend + + # ------------------------ + # Frontend + # ------------------------ + - name: Build frontend + run: | + docker build \ + --build-arg VITE_API_URL=https://anexo76-dev.aduanasoft.com/api/ \ + --build-arg VITE_KEYCLOAK_URL=https://anexo76-dev.aduanasoft.com/kcauth/ \ + --build-arg INTERNAL_API_URL=http://backend:3467/api/ \ + -t dev.aduanasoft.com/anexo76/frontend:latest \ + -f ./frontend/Dockerfile.prod \ + ./frontend + + # ------------------------ + # Push imágenes + # ------------------------ + - name: Push backend + run: | + docker push dev.aduanasoft.com/anexo76/backend:latest + + - name: Push frontend + run: | + docker push dev.aduanasoft.com/anexo76/frontend:latest + + # ------------------------ + # Crear tag de versión (solo para main) + # ------------------------ + - name: Crear tag de versión + if: github.ref == 'refs/heads/main' + run: | + VERSION=${{ steps.version.outputs.VERSION }} + git config user.name "Gitea Actions" + git config user.email "actions@gitea.local" + git tag -a "v${VERSION}" -m "Release version ${VERSION}" + git push origin "v${VERSION}" + echo "✅ Tag v${VERSION} creado y pusheado" + + # ------------------------ + # Deploy a Development + # ------------------------ + - name: Deploy a servidor de desarrollo + if: github.ref == 'refs/heads/development' + run: | + eval $(ssh-agent -s) + ssh-add <(echo "${{ secrets.DEV_SERVER_SSH_KEY }}") + ssh -o StrictHostKeyChecking=accept-new \ + ${{ secrets.DEV_SERVER_USER }}@${{ secrets.DEV_SERVER_HOST }} \ + 'cd ~/projects/anexo76 && \ + echo '"'"'${{ secrets.HARBOR_PASSWORD }}'"'"' | docker login dev.aduanasoft.com -u '"'"'${{ secrets.HARBOR_USERNAME }}'"'"' --password-stdin && \ + docker compose -f docker-compose.prod.yml pull && \ + docker compose -f docker-compose.prod.yml up -d && \ + docker image prune -f' + ssh-agent -k diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3126b16 --- /dev/null +++ b/.gitignore @@ -0,0 +1,85 @@ +# Python +__pycache__/ +.mypy_cache/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +.venv/ +ENV/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib64/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +.pnpm-store/ + +# Environment (no subir: cada quien puede usar puertos distintos vía .env) +.env +.env.e2e.generated +.env.local +backend/.env +frontend/.env +backend/SCRIPTS/ +.cursor/ +.claude/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +logs/ + +# Database +*.db +*.sqlite +*.sqlite3 +postgres_app_data/ +postgres_keycloak_data/ + +# Testing +backend/app_data/ +.mypy_cache/ +.pytest_cache/ +.coverage +htmlcov/ + +# Node (para frontend) +**/node_modules/ +**/.svelte-kit/ +.npm +.yarn + +# Docker +*.dockerignore +postgres-data/ +backend/uploads/ +backend/layouts/imports/ +docker-compose.yml +.mypy_cache/ + +# Celery +backend/celerybeat-schedule +celerybeat-schedule +celerybeat-schedule.* +celerybeat.pid +backend/api/v1/modules/reports/generated/ diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..a320589 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,452 @@ +// Pipeline CI/CD — Aduanasoft Anexo76 +// Ejecuta: tests+security (paralelo) → docker build/push (paralelo) → E2E → deploy dev → smoke test +// +// Credenciales requeridas en Jenkins: +// - harbor-credentials : username/password para dev.aduanasoft.com +// - a76-public-url-dev : Secret Text — URL pública base (ej. https://anexo76-dev.aduanasoft.com) +// - dev-server-user : Secret Text — usuario SSH del servidor dev +// - dev-server-host : Secret Text — hostname/IP del servidor dev +// - dev-server-ssh : SSH Username with Private Key — llave privada para deploy +// - a76-e2e-credentials : Username with password — usuario de prueba en Workspace +// - a76-sitar-api-url : Secret Text — URL del API SITAR (provee TC para "Consultar DOF") +// - a76-sitar-credentials : Username with password — credenciales del API SITAR +// +// El agente Jenkins solo necesita: Docker CLI + +pipeline { + agent any + + environment { + HARBOR_REGISTRY = 'dev.aduanasoft.com' + IMAGE_BACKEND = "${HARBOR_REGISTRY}/anexo76/backend" + IMAGE_FRONTEND = "${HARBOR_REGISTRY}/anexo76/frontend" + PYTHON_IMAGE = 'python:3.12-slim' + // Debe coincidir con @playwright/test del frontend (ver frontend/pnpm-lock.yaml) + PLAYWRIGHT_IMAGE = 'mcr.microsoft.com/playwright:v1.56.1-noble' + DEPLOY_PATH = '~/projects/anexo76' + // URL de Keycloak/Workspace — Keycloak vive en Workspace, no en el servidor de Anexo76 + KC_URL = 'https://workspace.aduanasoft.com/kcauth' + } + + options { + timeout(time: 45, unit: 'MINUTES') + buildDiscarder(logRotator(numToKeepStr: '10')) + disableConcurrentBuilds() + timestamps() + } + + stages { + + // ── Tests + Security (4 trabajos en paralelo) ───────────────────────── + // Backend tests, frontend Vitest, pip-audit y npm audit son independientes: + // todos en un solo bloque parallel — la stage tarda el máximo de los 4, + // no la suma. pip y pnpm cachean en volumes persistentes del agente + // (jenkins-a76-pip-cache, jenkins-a76-pnpm-store) para reusar deps + // entre builds (~30-60s menos cada uno cuando lockfiles no cambian). + stage('Tests & Security') { + parallel { + + stage('Test — Backend') { + steps { + sh ''' + set -euo pipefail + C="a76-test-py-${BUILD_NUMBER}" + DB_C="a76-test-db-${BUILD_NUMBER}" + NET="a76-test-net-${BUILD_NUMBER}" + DB_NAME="anexo76_test" + DB_USER="anexo76" + DB_PASS="anexo76" + TEST_DB_URL="postgresql://${DB_USER}:${DB_PASS}@${DB_C}:5432/${DB_NAME}" + + cleanup() { + docker rm -f "$C" >/dev/null 2>&1 || true + docker rm -f "$DB_C" >/dev/null 2>&1 || true + docker network rm "$NET" >/dev/null 2>&1 || true + } + trap cleanup EXIT + + docker network rm "$NET" >/dev/null 2>&1 || true + docker network create "$NET" + + docker rm -f "$DB_C" >/dev/null 2>&1 || true + docker run -d --name "$DB_C" \ + --network "$NET" \ + -e POSTGRES_DB="$DB_NAME" \ + -e POSTGRES_USER="$DB_USER" \ + -e POSTGRES_PASSWORD="$DB_PASS" \ + postgres:16-alpine + + # Esperar a que Postgres esté listo + READY=0 + for i in $(seq 1 30); do + if docker exec "$DB_C" pg_isready -U "$DB_USER" -d "$DB_NAME" >/dev/null 2>&1; then + READY=1; break + fi + sleep 2 + done + if [ "$READY" != "1" ]; then + echo "ERROR: Postgres no quedó listo en 60s." + exit 1 + fi + + docker exec "$DB_C" psql -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 -c " + CREATE SCHEMA IF NOT EXISTS core; + CREATE SCHEMA IF NOT EXISTS a24; + CREATE SCHEMA IF NOT EXISTS a76; + CREATE SCHEMA IF NOT EXISTS public; + " + + docker rm -f "$C" >/dev/null 2>&1 || true + # Volume persistente para el cache de pip (~/.cache/pip): + # primer build lo puebla, siguientes reusan wheels si requirements.txt no cambió. + docker run -d --name "$C" --network "$NET" \ + -v jenkins-a76-pip-cache:/root/.cache/pip \ + "$PYTHON_IMAGE" sleep infinity + docker cp "$WORKSPACE/backend/." "$C:/app" + + docker exec \ + -e TEST_DATABASE_URL="$TEST_DB_URL" \ + -w /app "$C" bash -lc ' + set -euxo pipefail + pip install --quiet -r requirements.txt + + # Prueba de migraciones: upgrade → downgrade → upgrade + # Verifica que todos los down() sean reversibles + echo "--- alembic upgrade head ---" + alembic upgrade head + echo "--- alembic downgrade base ---" + alembic downgrade base + echo "--- alembic upgrade head (restaurar para tests) ---" + alembic upgrade head + + # --cov=api,core: mide solo código fuente (excluye alembic, tests, layouts) + # setup.cfg contiene los patrones omit — ver backend/setup.cfg + # --cov-fail-under=25: umbral real actual (deuda técnica documentada) + # objetivo según estándares: 80% — incrementar conforme se agreguen tests + pytest tests/ \ + --cov=api \ + --cov=core \ + --cov-fail-under=25 \ + --cov-report=term-missing \ + --cov-report=xml \ + --junitxml=test-results.xml \ + -v -ra + ' + docker cp "$C:/app/test-results.xml" "$WORKSPACE/backend/test-results.xml" + docker cp "$C:/app/coverage.xml" "$WORKSPACE/backend/coverage.xml" + ''' + } + post { + always { + junit allowEmptyResults: true, testResults: 'backend/test-results.xml' + archiveArtifacts artifacts: 'backend/coverage.xml', allowEmptyArchive: true + } + } + } + + stage('Test — Frontend (Vitest)') { + steps { + sh ''' + set -euo pipefail + C="a76-test-fe-${BUILD_NUMBER}" + cleanup() { docker rm -f "$C" >/dev/null 2>&1 || true; } + trap cleanup EXIT + + docker rm -f "$C" >/dev/null 2>&1 || true + # Volume persistente para el store de pnpm (content-addressable): + # primer build lo puebla, siguientes reusan paquetes si pnpm-lock.yaml no cambió. + # Path estándar de pnpm@9 en Linux como root: /root/.local/share/pnpm/store + docker run -d --name "$C" \ + -v jenkins-a76-pnpm-store:/root/.local/share/pnpm/store \ + "$PLAYWRIGHT_IMAGE" sleep infinity + docker exec "$C" mkdir -p /workspace + docker cp "$WORKSPACE/." "$C:/workspace" + + docker exec \ + -e CI=true \ + -e NODE_ENV=test \ + -e "JENKINS_URL=${JENKINS_URL}" \ + -e JENKINS_VITEST_FULL=1 \ + -w /workspace/frontend \ + "$C" bash -lc ' + set -euxo pipefail + npm install -g pnpm@9 --quiet + pnpm install --frozen-lockfile + pnpm run i18n:compile + pnpm run test:unit -- --run + ' + ''' + } + } + + stage('pip-audit') { + steps { + sh ''' + set -euo pipefail + C="a76-audit-py-${BUILD_NUMBER}" + cleanup() { docker rm -f "$C" >/dev/null 2>&1 || true; } + trap cleanup EXIT + + docker rm -f "$C" >/dev/null 2>&1 || true + docker run -d --name "$C" \ + -v jenkins-a76-pip-cache:/root/.cache/pip \ + "$PYTHON_IMAGE" sleep infinity + docker cp "$WORKSPACE/backend/requirements.txt" "$C:/requirements.txt" + + docker exec "$C" bash -lc ' + pip install --quiet pip-audit + pip-audit -r /requirements.txt -f json -o /pip-audit-report.json || true + ' + docker cp "$C:/pip-audit-report.json" "$WORKSPACE/pip-audit-report.json" + ''' + archiveArtifacts artifacts: 'pip-audit-report.json', allowEmptyArchive: true + } + } + + stage('npm audit') { + steps { + sh ''' + set -euo pipefail + C="a76-audit-fe-${BUILD_NUMBER}" + cleanup() { docker rm -f "$C" >/dev/null 2>&1 || true; } + trap cleanup EXIT + + docker rm -f "$C" >/dev/null 2>&1 || true + docker run -d --name "$C" "$PLAYWRIGHT_IMAGE" sleep infinity + docker cp "$WORKSPACE/frontend/package.json" "$C:/package.json" + docker cp "$WORKSPACE/frontend/pnpm-lock.yaml" "$C:/pnpm-lock.yaml" 2>/dev/null || true + + docker exec -w / "$C" bash -lc ' + npm audit --audit-level=high || true + ' + ''' + } + } + + } + } + + // ── Generación de versión ───────────────────────────────────────────── + stage('Generate version') { + when { + anyOf { branch 'main'; branch 'development' } + } + steps { + script { + def year = sh(returnStdout: true, script: 'date +%y').trim() + def month = sh(returnStdout: true, script: 'date +%m').trim() + def shortHash = sh(returnStdout: true, script: 'git rev-parse --short=8 HEAD').trim() + + if (env.BRANCH_NAME == 'main') { + def lastTag = sh(returnStdout: true, + script: 'git describe --tags --abbrev=0 2>/dev/null || true').trim() + def commitCount = lastTag + ? sh(returnStdout: true, script: "git rev-list ${lastTag}..HEAD --count").trim() + : sh(returnStdout: true, script: 'git rev-list --count HEAD').trim() + if (commitCount == '0') commitCount = '1' + env.APP_VERSION = "${year}.${month}.1.${commitCount}" + } else { + env.APP_VERSION = "${year}.${month}.1.${shortHash}" + } + + echo "VERSION: ${env.APP_VERSION}" + } + } + } + + // ── Docker Build + Push (backend y frontend en paralelo) ────────────── + // Antes era secuencial: ~6-10 min totales. Ahora cada imagen builda+pushea + // en su propio substage en paralelo → tarda el máximo de los dos. + // BuildKit inline cache + --cache-from reusan las layers de :latest cuando + // pip-lock / pnpm-lock no cambiaron (~1-3 min menos por imagen). + stage('Docker Build') { + when { + anyOf { branch 'main'; branch 'development' } + } + parallel { + + stage('Build & Push — Backend') { + steps { + withCredentials([ + usernamePassword( + credentialsId: 'harbor-credentials', + usernameVariable: 'HARBOR_USER', + passwordVariable: 'HARBOR_PASS' + ) + ]) { + sh """ + set -euo pipefail + export DOCKER_BUILDKIT=1 + echo "\${HARBOR_PASS}" | docker login ${HARBOR_REGISTRY} -u "\${HARBOR_USER}" --password-stdin + + # Pull de :latest para alimentar --cache-from (silencioso si no existe) + docker pull ${IMAGE_BACKEND}:latest >/dev/null 2>&1 || true + + docker build --progress=plain \\ + --cache-from ${IMAGE_BACKEND}:latest \\ + --build-arg BUILDKIT_INLINE_CACHE=1 \\ + --build-arg APP_VERSION=${env.APP_VERSION} \\ + -t ${IMAGE_BACKEND}:${env.APP_VERSION} \\ + -t ${IMAGE_BACKEND}:latest \\ + -f backend/Dockerfile \\ + backend/ + + docker push ${IMAGE_BACKEND}:${env.APP_VERSION} + docker push ${IMAGE_BACKEND}:latest + """ + } + } + } + + stage('Build & Push — Frontend') { + steps { + withCredentials([ + usernamePassword( + credentialsId: 'harbor-credentials', + usernameVariable: 'HARBOR_USER', + passwordVariable: 'HARBOR_PASS' + ), + string(credentialsId: 'a76-public-url-dev', variable: 'A76_URL') + ]) { + sh """ + set -euo pipefail + export DOCKER_BUILDKIT=1 + echo "\${HARBOR_PASS}" | docker login ${HARBOR_REGISTRY} -u "\${HARBOR_USER}" --password-stdin + + docker pull ${IMAGE_FRONTEND}:latest >/dev/null 2>&1 || true + + docker build --progress=plain \\ + --cache-from ${IMAGE_FRONTEND}:latest \\ + --build-arg BUILDKIT_INLINE_CACHE=1 \\ + --build-arg VITE_API_URL=\${A76_URL}/api/ \\ + --build-arg VITE_KEYCLOAK_URL=${KC_URL} \\ + --build-arg VITE_KEYCLOAK_CLIENT_ID=anexo76-frontend \\ + --build-arg INTERNAL_API_URL=http://backend:3467/api/ \\ + -t ${IMAGE_FRONTEND}:${env.APP_VERSION} \\ + -t ${IMAGE_FRONTEND}:latest \\ + -f frontend/Dockerfile.prod \\ + frontend/ + + docker push ${IMAGE_FRONTEND}:${env.APP_VERSION} + docker push ${IMAGE_FRONTEND}:latest + """ + } + } + } + + } + } + + // ── E2E (Playwright) — DESACTIVADO TEMPORALMENTE ─────────────────────── + // El stage completo de E2E se ha comentado temporalmente mientras se + // estabiliza el flujo de Workspace / Fixed Assets y los tests Playwright. + // Para reactivarlo, recuperar la definición anterior de stage('E2E (Playwright)'). + + // ── Deploy Dev ──────────────────────────────────────────────────────── + stage('Deploy — Dev') { + when { branch 'development' } + steps { + withCredentials([ + usernamePassword( + credentialsId: 'harbor-credentials', + usernameVariable: 'HARBOR_USER', + passwordVariable: 'HARBOR_PASS' + ), + string(credentialsId: 'dev-server-user', variable: 'DEV_SERVER_USER'), + string(credentialsId: 'dev-server-host', variable: 'DEPLOY_HOST'), + sshUserPrivateKey( + credentialsId: 'dev-server-ssh', + keyFileVariable: 'SSH_KEY', + usernameVariable: 'SSH_USER', + passphraseVariable: 'SSH_PASSPHRASE' + ) + ]) { + script { + def deployHost = env.DEPLOY_HOST?.trim() + def knownHostsFile = "${env.WORKSPACE}/.jenkins-a76-known_hosts" + sh "ssh-keyscan -T 15 -H '${deployHost}' > '${knownHostsFile}'" + def remote = [ + name : 'dev-server', + host : deployHost, + user : env.DEV_SERVER_USER ?: env.SSH_USER, + identityFile : env.SSH_KEY, + passphrase : env.SSH_PASSPHRASE, + allowAnyHosts: false, + knownHosts : knownHostsFile + ] + sshCommand remote: remote, command: """ + set -euo pipefail + cd ${DEPLOY_PATH} + APP_VERSION=${env.APP_VERSION} docker compose -f docker-compose.prod.yml pull + APP_VERSION=${env.APP_VERSION} docker compose -f docker-compose.prod.yml up -d || { + echo '=== logs anexo76-backend ===' + docker logs --tail 100 anexo76-backend 2>&1 || true + echo '=== docker compose ps ===' + docker compose -f docker-compose.prod.yml ps -a 2>&1 || true + exit 1 + } + docker image prune -f + """ + } + } + } + } + + // ── Smoke Test ──────────────────────────────────────────────────────── + stage('Smoke Test') { + when { branch 'development' } + steps { + withCredentials([ + string(credentialsId: 'dev-server-host', variable: 'DEPLOY_HOST'), + sshUserPrivateKey( + credentialsId: 'dev-server-ssh', + keyFileVariable: 'SSH_KEY', + usernameVariable: 'SSH_USER', + passphraseVariable: 'SSH_PASSPHRASE' + ) + ]) { + script { + def deployHost = env.DEPLOY_HOST?.trim() + def knownHostsFile = "${env.WORKSPACE}/.jenkins-a76-known_hosts" + sh "ssh-keyscan -T 15 -H '${deployHost}' > '${knownHostsFile}'" + def remote = [ + name : 'dev-server', + host : deployHost, + user : env.SSH_USER, + identityFile : env.SSH_KEY, + passphrase : env.SSH_PASSPHRASE, + allowAnyHosts: false, + knownHosts : knownHostsFile + ] + sshCommand remote: remote, command: ''' + set -euo pipefail + for i in $(seq 1 12); do + STATUS=$(curl -sf -o /dev/null -w "%{http_code}" http://localhost:3467/api/health 2>/dev/null || true) + if [ "$STATUS" = "200" ]; then + echo "Smoke test OK — HTTP $STATUS" + exit 0 + fi + echo "Intento $i/12 — HTTP ${STATUS:-000} — reintentando en 10s..." + sleep 10 + done + echo "ERROR: Smoke test fallido tras 120s" + exit 1 + ''' + } + } + } + } + } + + post { + always { + sh "docker logout ${HARBOR_REGISTRY} || true" + } + success { + echo "Pipeline OK — ${env.BRANCH_NAME} #${env.BUILD_NUMBER} (${env.APP_VERSION ?: 'n/a'})" + } + failure { + echo "PIPELINE FALLIDO — ${env.BRANCH_NAME} #${env.BUILD_NUMBER}" + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..d300d73 --- /dev/null +++ b/README.md @@ -0,0 +1,156 @@ +# Plantilla Workspace — Aduanasoft + +Plantilla base para nuevos proyectos del ecosistema **Workspace de Aduanasoft**. +Incluye autenticación SSO con Keycloak/Hub, arquitectura multi-tenant, y un dashboard +funcional listo para extender. + +--- + +## Stack + +| Capa | Tecnología | +|---|---| +| Frontend | SvelteKit 5 + Tailwind CSS | +| Backend | FastAPI + SQLAlchemy + PostgreSQL | +| Auth | Keycloak (OpenID Connect) vía Workspace Hub | +| Cache / Queue | Valkey (Redis) + Celery | +| Storage | MinIO (S3-compatible) | +| Contenedores | Docker Compose | + +--- + +## Estructura del proyecto + +``` +├── backend/ +│ ├── api/v1/modules/ +│ │ ├── core/ # Auth, usuarios, tenants, permisos, licencias +│ │ └── example/ # Módulo de referencia — copia y renombra +│ ├── core/ # Config, seguridad, DB, middleware +│ └── alembic/ # Migraciones (solo esquema core) +│ +├── frontend/ +│ ├── src/routes/ +│ │ ├── auth/ # Callback OAuth2, SSO, logout +│ │ ├── login/ # Login local (dev) o redirect al workspace +│ │ └── dashboard/ # Shell + rutas stub +│ └── src/lib/ +│ ├── server/ # workspace-auth, workspace-apps, api SSR +│ └── stores/ # company, system, workspace-apps +│ +└── scripts/ + └── auth-mode.sh # Alterna entre modo local y workspace +``` + +--- + +## Inicio rápido + +### 1. Clonar y configurar + +```bash +git clone https://git.aduanasoft.com/ADUANASOFT/plantillas-proyectos.git mi-proyecto +cd mi-proyecto +``` + +Renombrar el proyecto en `.env` y `docker-compose.yml`: +- `CORE_DB_NAME=app_core` → `mi_proyecto_core` +- `name: app` en `docker-compose.yml` → `mi-proyecto` + +### 2. Levantar en modo local (sin workspace) + +```bash +./scripts/auth-mode.sh local +docker compose up -d +``` + +Abre `http://localhost:5173` → click **"Entrar como dev"**. + +### 3. Conectar al workspace + +```bash +./scripts/auth-mode.sh workspace +# Pregunta: +# URL del workspace → https://workspace.aduanasoft.com +# Keycloak Realm → master +# Keycloak Client ID → nombre-del-client +``` + +> El equipo del Hub debe registrar la app y agregar el redirect URI: +> `http://localhost:5173/auth/callback` + +--- + +## Variables de entorno + +El `.env` usa una sola URL base para derivar toda la configuración del workspace: + +```env +# Una variable, todo se deriva de aquí +WORKSPACE_URL=https://workspace.aduanasoft.com +KEYCLOAK_REALM=master +KEYCLOAK_CLIENT_ID=mi-app-frontend + +# Auth local (desarrollo sin workspace) +DEV_LOCAL_AUTH=False # True = botón "Entrar como dev" +SECRET_KEY=... # Se genera automáticamente con auth-mode.sh local +``` + +El `docker-compose.yml` construye automáticamente: +- `HUB_URL` = `${WORKSPACE_URL}` +- `VITE_KEYCLOAK_URL` = `${WORKSPACE_URL}/kcauth` + +--- + +## Agregar un módulo nuevo + +### Backend + +Copia `backend/api/v1/modules/example/` y renombra: + +``` +my_module/ +├── __init__.py +├── models.py # SQLAlchemy — hereda TenantScopedMixin + TimestampMixin +├── dto.py # Pydantic v2 — Request / Response separados +├── service.py # Lógica de negocio, recibe db + tenant_id + company_id +└── routes.py # FastAPI router, usa Depends(get_current_user) +``` + +Registrar en `backend/api/v1/router.py`: + +```python +from .modules.my_module.routes import router as my_router +router.include_router(my_router, prefix="/my-module", tags=["my-module"]) +``` + +### Frontend + +Crear `frontend/src/routes/dashboard/my-module/+page.svelte` y agregar al sidebar en +`frontend/src/lib/components/sidebar/modules.ts`: + +```typescript +{ title: 'Mi módulo', url: '/dashboard/my-module', icon: MyIcon } +``` + +--- + +## Script auth-mode + +```bash +./scripts/auth-mode.sh status # Ver modo actual +./scripts/auth-mode.sh local # Activar login local (dev sin workspace) +./scripts/auth-mode.sh workspace # Configurar y conectar al workspace +``` + +Al cambiar de modo el script pregunta si reiniciar los contenedores automáticamente. + +--- + +## Convenciones + +- **Commits**: Conventional Commits (`feat:`, `fix:`, `refactor:`, `chore:`) +- **Branches**: `feature/DESC`, `fix/DESC` +- **Nombres en código**: inglés; comentarios de lógica de negocio en español +- **Backend**: Pydantic v2, async por default, routers por dominio +- **Frontend**: SvelteKit 5 runes (`$state`, `$derived`, `$effect`), Tailwind utility-first diff --git a/archivo.txt b/archivo.txt new file mode 100644 index 0000000..e69de29 diff --git a/azure.crt b/azure.crt new file mode 100644 index 0000000..f50e5bb --- /dev/null +++ b/azure.crt @@ -0,0 +1,44 @@ +-----BEGIN CERTIFICATE----- +MIIH1jCCBr6gAwIBAgIQC6Mxbk470/aejYi9ZXtTGjANBgkqhkiG9w0BAQsFADBN +MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMScwJQYDVQQDEx5E +aWdpQ2VydCBTSEEyIFNlY3VyZSBTZXJ2ZXIgQ0EwHhcNMjUwOTIyMDAwMDAwWhcN +MjYwMzIyMjM1OTU5WjB/MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3Rv +bjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0 +aW9uMSkwJwYDVQQDEyBzdGFtcDIubG9naW4ubWljcm9zb2Z0b25saW5lLmNvbTCC +ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAM3K1uJNEzm2EnB1LY34OThA +fC0P/F5bue+IV4GnTxjmfDTeWBW4VcInt6Row7UyCNh6EyRXIGyr1zJr34HI2NcF +5TgkftNCDua8v3SivzknmVnXNVj51ct70+UBjgN6CMhl9/b61R7nguQIVs+GdyoX +deFqgMn+awDEmLcjUS3ijw1OVbf3O5Oha5LZwxmTx3gDkb+kwH7Tba2gIr7IvVQe +dbfIO0eYQKyBCPvyCiuNS3YHGEMug+I5y1ycPQV+OmrxYbEjoS3m2cic0YV/P7xY +vdx8O2XUAVTzfJs5GAkY+IiNtpG5oaZYXJbbxIM5v11CyYeJthfpdALGA5MkiR0C +AwEAAaOCBH4wggR6MB8GA1UdIwQYMBaAFA+AYRyCMWHVLyjnjUY4tCzhxtniMB0G +A1UdDgQWBBQiLJufwFGrmBabwkp+oOZQ2USotzCCASYGA1UdEQSCAR0wggEZgiBz +dGFtcDIubG9naW4ubWljcm9zb2Z0b25saW5lLmNvbYIdbG9naW4ubWljcm9zb2Z0 +b25saW5lLWludC5jb22CG2xvZ2luLm1pY3Jvc29mdG9ubGluZS1wLmNvbYIZbG9n +aW4ubWljcm9zb2Z0b25saW5lLmNvbYIebG9naW4yLm1pY3Jvc29mdG9ubGluZS1p +bnQuY29tghpsb2dpbjIubWljcm9zb2Z0b25saW5lLmNvbYIfbG9naW5leC5taWNy +b3NvZnRvbmxpbmUtaW50LmNvbYIbbG9naW5leC5taWNyb3NvZnRvbmxpbmUuY29t +giRzdGFtcDIubG9naW4ubWljcm9zb2Z0b25saW5lLWludC5jb20wPgYDVR0gBDcw +NTAzBgZngQwBAgIwKTAnBggrBgEFBQcCARYbaHR0cDovL3d3dy5kaWdpY2VydC5j +b20vQ1BTMA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYB +BQUHAwIwgY0GA1UdHwSBhTCBgjA/oD2gO4Y5aHR0cDovL2NybDMuZGlnaWNlcnQu +Y29tL0RpZ2ljZXJ0U0hBMlNlY3VyZVNlcnZlckNBLTEuY3JsMD+gPaA7hjlodHRw +Oi8vY3JsNC5kaWdpY2VydC5jb20vRGlnaWNlcnRTSEEyU2VjdXJlU2VydmVyQ0Et +MS5jcmwwfgYIKwYBBQUHAQEEcjBwMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5k +aWdpY2VydC5jb20wSAYIKwYBBQUHMAKGPGh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0 +LmNvbS9EaWdpQ2VydFNIQTJTZWN1cmVTZXJ2ZXJDQS0yLmNydDAMBgNVHRMBAf8E +AjAAMIIBfwYKKwYBBAHWeQIEAgSCAW8EggFrAWkAdgCWl2S/VViXrfdDh2g3CEJ3 +6fA61fak8zZuRqQ/D8qpxgAAAZlxQ3dQAAAEAwBHMEUCICRSWP825/83352Dv6WQ +IcXT2bmT1D6lrJQ1W6U+6PX0AiEA7XUiPn8JJmJWCK0jUS/ZrOUEKf1cML3MWeQg +6WX7MfMAdwBkEcRspBLsp4kcogIuALyrTygH1B41J6vq/tUDyX3N8AAAAZlxQ3dT +AAAEAwBIMEYCIQCL6HjqOV6VliTqVirdscl+wjEPNfq/fkzm26WJ5m26yQIhANJ9 +Gc6Kt5AX15DNNJ5ukpAWgtENmpT3M+oTw/7SB0SdAHYASZybad4dfOz8Nt7Nh2Sm +uFuvCoeAGdFVUvvp6ynd+MMAAAGZcUN3bwAABAMARzBFAiEAssa9lKaXdaV7UbAj +ZwpILJrsHrszTewahn6yIo24XYoCIGl+1U3yR/RN/Ox3mNlPpJO2tDSkxcUBk3mR +BaNcrLcuMA0GCSqGSIb3DQEBCwUAA4IBAQBIru75Gq6GMI/GG+3fLKFT+NYwHqHq +J97CcTPwFRW0iv3EKKEZCyxB5su+gB6JkYFq4B+n7KZgmkuIXyO50uAgJ3toeYNs +S9WWVTqL2ETWSUl+4iNbTXnaj2/eW+27OJcM6z0phQbu/uZh8wsD2pJzv7y6isyF +5FbvC4pbb+z9LfAUH7D3kJEYEjZfD4c/WZH13s0rSlXmBPXweOFGEFCeCO1/BI0Q +2l5PdbgYAtCYUwZl3Vy7/J8uhrs0papWAMauKZWrvTauCtKcPjSI8oRwsVWrf0vL +dcWYPQmaWGxUd1+mEqcB9OiB7lTlwx+ipRJDURninhn1dOef6+qg9oV2 +-----END CERTIFICATE----- diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..43973d9 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,39 @@ +# Application +APP_NAME=Mi Aplicación +APP_VERSION=1.0.0 +DEBUG=True +ENVIRONMENT=development + +# Auth local para desarrollo (sin Keycloak/Hub) +# Cambia a True para entrar sin workspace. NUNCA en producción. +DEV_LOCAL_AUTH=False +DEV_LOCAL_AUTH_EMAIL=dev@local.test +DEV_LOCAL_AUTH_NAME=Dev User +DEV_LOCAL_AUTH_TENANT_ID=1 +DEV_LOCAL_AUTH_COMPANY_ID=1 + +# Database - Core +CORE_DB_HOST=localhost +CORE_DB_PORT=5432 +CORE_DB_NAME=anexo76_core +CORE_DB_USER=postgres +CORE_DB_PASSWORD=postgres + +# CORS +CORS_ORIGINS=http://localhost:5173,http://localhost:3000 + +# Hub de Aduanasoft — requerido siempre (SaaS y self-hosted) +HUB_URL=https://hub.aduanasoft.com + +# Factura COVE / VUCEM / DODA / API Ventanilla Única +# Llave y IV AES-256-CBC para cifrar la clave FIEL. +COVE_FIEL_HASH_KEY= +COVE_FIEL_HASH_IV= +# URL base del API de Ventanilla Única (COVE, Expediente y DODA comparten esta variable). +COVE_API_URL=https://api.vu.aduanasoft.com +# Verificación SSL para el API de VU (False en redes internas / dev, True en producción). +COVE_API_VERIFY_SSL=False + +# Sincronización (Hub & Spoke) +SYNC_SECRET_TOKEN=change-this-sync-token-in-production +CENTRAL_SERVER_URL= diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..c60f0bb --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,62 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Instalar dependencias del sistema +RUN apt-get update && apt-get install -y \ + gcc \ + postgresql-client \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Instalar dependencias para wkhtmltopdf y reportes PDF +RUN apt-get update \ + && apt-get install -y \ + xvfb \ + fontconfig \ + fonts-dejavu-core \ + libfontconfig1 \ + libxrender1 \ + libxtst6 \ + libxi6 \ + libxrandr2 \ + ca-certificates \ + libjpeg62-turbo \ + libpng16-16 \ + && rm -rf /var/lib/apt/lists/* + +# Instalar wkhtmltopdf binario oficial con soporte para footers/headers +# TARGETARCH permite amd64 y arm64 (Apple Silicon) +ARG TARGETARCH +RUN curl -k -L -o /tmp/wkhtmltox.deb "https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.bookworm_${TARGETARCH}.deb" \ + && apt-get update \ + && apt-get install -y /tmp/wkhtmltox.deb \ + && rm /tmp/wkhtmltox.deb \ + && rm -rf /var/lib/apt/lists/* \ + && wkhtmltopdf --version + + +# Copiar requirements +COPY requirements.txt . + +# Instalar dependencias Python +RUN pip install --no-cache-dir -r requirements.txt + +# Entrypoint (espera Postgres/Keycloak; no montar desde el host) +COPY docker-entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Copiar código +COPY . . + +# Jenkins / CI pasan --build-arg APP_VERSION=…; debe quedar en ENV para runtime (API /version, OpenAPI, etc.) +ARG APP_VERSION=dev-local +ENV APP_VERSION=${APP_VERSION} + +# Exponer puerto +EXPOSE 8000 + +ENTRYPOINT ["/entrypoint.sh"] + +# Comando por defecto +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..dc17043 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,148 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. + +sqlalchemy.url = postgresql://${CORE_DB_USER}:${CORE_DB_PASSWORD}@${CORE_DB_HOST}:${CORE_DB_PORT}/${CORE_DB_NAME} + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/README b/backend/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/backend/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..f9dd3b1 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,363 @@ +import importlib.util +import logging +import os +import sys +from logging.config import fileConfig +from urllib.parse import quote_plus + +from alembic import context +from alembic.operations import ops +from core.config import settings +from core.database import Base +from sqlalchemy import engine_from_config, pool +from sqlalchemy.engine.url import make_url + +logger = logging.getLogger(__name__) + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + + +def _strip_env_url(raw: str) -> str: + """Quita espacios/comillas típicos de secretos CI (.env, Gitea).""" + url = raw.strip().strip('"').strip("'") + return url + + +def _normalize_alembic_sqlalchemy_url(url: str) -> str: + """Alembic usa el driver síncrono psycopg2; normaliza DSN típicos de app/tests.""" + url = _strip_env_url(url) + if url.startswith("postgresql+asyncpg://"): + return url.replace("postgresql+asyncpg://", "postgresql+psycopg2://", 1) + if url.startswith("postgresql+psycopg2://"): + return url + if url.startswith("postgresql://"): + return url.replace("postgresql://", "postgresql+psycopg2://", 1) + if url.startswith("postgres://"): + return url.replace("postgres://", "postgresql+psycopg2://", 1) + return url + + +def _validate_sqlalchemy_url(url: str, env_key: str) -> None: + """Misma validación que create_engine; evita urlparse (falla con esquemas tipo postgresql+psycopg2).""" + try: + make_url(url) + except Exception as e: + raise RuntimeError( + f"{env_key} no es una URL de SQLAlchemy válida. " + "Ejemplo: postgresql://usuario:clave@127.0.0.1:5432/nombre_bd" + ) from e + + +def _reject_documentation_placeholder_host(url: str, source: str) -> None: + """ + Evita el error críptico de DNS: muchos ejemplos usan @host:5432 como texto literal. + """ + try: + parsed = make_url(url) + except Exception: + return + h = (parsed.host or "").strip().lower() + if h == "host": + raise RuntimeError( + f"{source}: el hostname \"host\" es un placeholder de documentación, no un servidor real. " + "Usa el host alcanzable desde el runner (IP, nombre DNS, servicio en docker-compose, " + "o host.docker.internal si act corre en contenedor y Postgres en tu máquina)." + ) + + +def get_database_url(): + """Obtiene la URL de la base de datos (PostgreSQL) desde variables de entorno o alembic.ini.""" + # CI / pytest: misma URL que los tests (secret TEST_DATABASE_URL) o DATABASE_URL explícita. + # CRÍTICO: debe ser tupla con coma final si un solo elemento: ("X",) — si no, ("X") es str y el for + # itera caracteres; env_key "_" + os.environ["_"] (común en shells) rompe con URL inválida. + for env_key in ("TEST_DATABASE_URL", "DATABASE_URL"): + raw = os.environ.get(env_key) + if raw and raw.strip(): + normalized = _normalize_alembic_sqlalchemy_url(raw) + _validate_sqlalchemy_url(normalized, env_key) + _reject_documentation_placeholder_host(normalized, env_key) + return normalized + + # Construcción desde settings (CORE_DB_* en .env / entorno) + host = settings.CORE_DB_HOST + db = settings.CORE_DB_NAME + user = settings.CORE_DB_USER + password = settings.CORE_DB_PASSWORD + port = settings.CORE_DB_PORT + + if host and db and user and password: + try: + encoded_user = quote_plus(user) + encoded_password = quote_plus(password) + encoded_db = quote_plus(db) + built = f"postgresql+psycopg2://{encoded_user}:{encoded_password}@{host}:{port}/{encoded_db}" + _reject_documentation_placeholder_host(built, "CORE_DB_HOST") + return built + except Exception as e: + logger.error(f"Error al construir URL: {e}") + + # Fallback al archivo de configuración + url = config.get_main_option("sqlalchemy.url") + if not url or "${" in url or "%(" in url: + raise RuntimeError( + "No se ha configurado la cadena de conexión a PostgreSQL. " + "Define TEST_DATABASE_URL o DATABASE_URL, o variables CORE_DB_*; " + "sqlalchemy.url en alembic.ini con placeholders ${...} no está soportado." + ) + + _reject_documentation_placeholder_host(url, "alembic.ini sqlalchemy.url") + return url + + +# Configurar la URL de la base de datos +database_url = get_database_url() + +# Debug: mostrar la URL (sin la contraseña) +if os.environ.get("ALEMBIC_DEBUG"): + # Ocultar la contraseña para el debug en la URL + try: + before, after = database_url.split("@", 1) + if ":" in before: + before = before.split(":", 1)[0] + ":***" + debug_url = before + "@" + after + except Exception: + debug_url = "postgresql://***:***@***" + logger.error("Error al ocultar la contraseña en la URL para debug.") + +config.set_main_option("sqlalchemy.url", database_url) + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Ajusta la ruta para que puedas importar core y módulos +BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +sys.path.insert(0, BASE_DIR) + +# Configuración de Alembic +config = context.config +fileConfig(config.config_file_name) +target_metadata = Base.metadata + + +def include_object(object_, name, type_, reflected, compare_to): + """ + Keep all objects in autogenerate. + FK noise is cleaned in process_revision_directives. + """ + return True + + +def _fk_drop_signature(op_): + if not isinstance(op_, ops.DropConstraintOp): + return None + if getattr(op_, "constraint_type", None) != "foreignkey": + return None + return ( + getattr(op_, "schema", None), + getattr(op_, "table_name", None), + getattr(op_, "constraint_name", None), + ) + + +def _fk_create_signature(op_): + if not isinstance(op_, ops.CreateForeignKeyOp): + return None + local_cols = tuple(getattr(op_, "local_cols", ()) or ()) + remote_cols = tuple(getattr(op_, "remote_cols", ()) or ()) + return ( + getattr(op_, "source_schema", None), + getattr(op_, "source_table", None), + getattr(op_, "referent_schema", None), + getattr(op_, "referent_table", None), + local_cols, + remote_cols, + ) + + +def _drop_to_create_match(drop_op, create_op): + if not isinstance(drop_op, ops.DropConstraintOp): + return False + if not isinstance(create_op, ops.CreateForeignKeyOp): + return False + if getattr(drop_op, "constraint_type", None) != "foreignkey": + return False + + def _normalize_schema(value): + # PostgreSQL reports default schema inconsistently as None/public. + return "public" if value in (None, "") else value + + # Prefer structural comparison using Alembic's reverse op when available. + reverse_create = getattr(drop_op, "_reverse", None) + if isinstance(reverse_create, ops.CreateForeignKeyOp): + return ( + _normalize_schema(getattr(reverse_create, "source_schema", None)) + == _normalize_schema(getattr(create_op, "source_schema", None)) + and getattr(reverse_create, "source_table", None) == getattr(create_op, "source_table", None) + and _normalize_schema(getattr(reverse_create, "referent_schema", None)) + == _normalize_schema(getattr(create_op, "referent_schema", None)) + and getattr(reverse_create, "referent_table", None) == getattr(create_op, "referent_table", None) + and tuple(getattr(reverse_create, "local_cols", ()) or ()) + == tuple(getattr(create_op, "local_cols", ()) or ()) + and tuple(getattr(reverse_create, "remote_cols", ()) or ()) + == tuple(getattr(create_op, "remote_cols", ()) or ()) + ) + + # Fallback for older op payloads: compare source table/schema and name. + return ( + _normalize_schema(getattr(drop_op, "schema", None)) == _normalize_schema(getattr(create_op, "source_schema", None)) + and getattr(drop_op, "table_name", None) == getattr(create_op, "source_table", None) + and getattr(drop_op, "constraint_name", None) == getattr(create_op, "constraint_name", None) + ) + + +def _prune_fk_churn(container): + if not hasattr(container, "ops"): + return + + # First recurse into nested containers. + for op_ in list(container.ops): + _prune_fk_churn(op_) + + table_ops = container.ops + kept_ops = [] + consumed_indexes = set() + + for i, op_i in enumerate(table_ops): + if i in consumed_indexes: + continue + + if isinstance(op_i, ops.DropConstraintOp) and getattr(op_i, "constraint_type", None) == "foreignkey": + matched_j = None + for j in range(i + 1, len(table_ops)): + if j in consumed_indexes: + continue + op_j = table_ops[j] + if _drop_to_create_match(op_i, op_j): + matched_j = j + break + if matched_j is not None: + # Drop + recreate same FK detected; remove both. + consumed_indexes.add(i) + consumed_indexes.add(matched_j) + continue + + kept_ops.append(op_i) + + container.ops = kept_ops + + +def process_revision_directives(context_, revision, directives): + """ + Remove autogenerate noise where Alembic emits drop/create for equivalent FKs. + Real FK changes are preserved. + """ + if not directives: + return + script = directives[0] + _prune_fk_churn(script.upgrade_ops) + _prune_fk_churn(script.downgrade_ops) + + +def import_models_from_dir(dir_path: str): + """Importa recursivamente cualquier archivo models.py desde dir_path y archivos en directorios models/""" + import sys + + def _load_module(module_path: str): + rel_path = os.path.relpath(module_path, BASE_DIR) + module_name = rel_path.replace(os.sep, ".").replace(".py", "") + # Skip if already loaded to avoid duplicate SQLAlchemy table registrations + if module_name in sys.modules: + return + spec = importlib.util.spec_from_file_location(module_name, module_path) + mod = importlib.util.module_from_spec(spec) + # Register in sys.modules before exec so transitive imports resolve correctly + sys.modules[module_name] = mod + spec.loader.exec_module(mod) + + for root, dirs, files in os.walk(dir_path): + # Importar archivos models.py directos + if "models.py" in files: + try: + _load_module(os.path.join(root, "models.py")) + except Exception as e: + logger.warning(f"No se pudo importar {os.path.join(root, 'models.py')}: {e}") + + # Importar todos los archivos .py en directorios llamados "models" + if os.path.basename(root) == "models": + for file in files: + if file.endswith(".py") and not file.startswith("__"): + try: + _load_module(os.path.join(root, file)) + except Exception as e: + logger.warning(f"No se pudo importar {os.path.join(root, file)}: {e}") + + +# Importar todos los models dentro de api/v1/modules y api/v1/modules/uploads +modules_dir = os.path.join(BASE_DIR, "api", "v1", "modules") +import_models_from_dir(modules_dir) + +# Tablas declaradas fuera de models.py / carpeta models/ (autogenerate) +# Agrega aquí imports de models que no estén en archivos models.py estándar. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + compare_type=True, + include_schemas=True, + include_object=include_object, + process_revision_directives=process_revision_directives, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + include_schemas=True, + include_object=include_object, + process_revision_directives=process_revision_directives, + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/8c9bad3da37f_seed_initial_data.py b/backend/alembic/versions/8c9bad3da37f_seed_initial_data.py new file mode 100644 index 0000000..94ca24a --- /dev/null +++ b/backend/alembic/versions/8c9bad3da37f_seed_initial_data.py @@ -0,0 +1,24 @@ +"""seed_initial_data + +Revision ID: 8c9bad3da37f +Revises: 9db46c604463 +Create Date: 2026-05-01 22:01:50.174319 + +Nota: la migración original sembraba catálogos de referencia de Anexo 76. +En la plantilla este paso es un no-op — agrega tus seeds aquí si los necesitas. +""" + +from typing import Sequence, Union + +revision: str = "8c9bad3da37f" +down_revision: Union[str, None] = "9db46c604463" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/backend/alembic/versions/9db46c604463_initial_schema.py b/backend/alembic/versions/9db46c604463_initial_schema.py new file mode 100644 index 0000000..0196276 --- /dev/null +++ b/backend/alembic/versions/9db46c604463_initial_schema.py @@ -0,0 +1,5091 @@ +"""initial_schema + +Revision ID: 9db46c604463 +Revises: +Create Date: 2026-05-01 22:01:23.130310 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '9db46c604463' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_TABLES_TENANT_ONLY: list[tuple[str, str]] = [ + ("a76", "company"), + ("core", "license_usage"), + ("core", "licenses"), +] + +_TABLES_TENANT_AND_COMPANY: list[tuple[str, str]] = [ + ("a24", "balance_movement"), + ("a24", "discharge_detail"), + ("a24", "discharge_header"), + ("a24", "discharge_scrap"), + ("a24", "fa_classes"), + ("a24", "fa_item_lines"), + ("a24", "fa_partes"), + ("a24", "inv_aphis_characteristic"), + ("a24", "inv_aphis_containers"), + ("a24", "inv_aphis_entities"), + ("a24", "inv_aphis_general"), + ("a24", "inv_aphis_lpcos"), + ("a24", "inv_aphis_routing"), + ("a24", "inv_aphis_stype_pitems"), + ("a24", "inv_bom"), + ("a24", "inv_classes"), + ("a24", "inv_parte_paises"), + ("a24", "inv_partes"), + ("a76", "app_settings"), + ("a76", "audit_logs"), + ("a76", "canadian_tariff_fractions"), + ("a76", "classes"), + ("a76", "classification_concepts"), + ("a76", "clients_and_providers"), + ("a76", "clients_and_providers_address"), + ("a76", "clients_and_providers_programs"), + ("a76", "concept_manifestations"), + ("a76", "concepts"), + ("a76", "country_rule_oct"), + ("a76", "ctm_receipts"), + ("a76", "customs_broker_concepts"), + ("a76", "customs_brokers"), + ("a76", "customs_brokers_personnel"), + ("a76", "customs_brokers_vu"), + ("a76", "depreciation_catalog"), + ("a76", "document_types_digitization"), + ("a76", "doda"), + ("a76", "doda_alta_log"), + ("a76", "doda_american_pedimentos"), + ("a76", "doda_container_seals"), + ("a76", "doda_containers"), + ("a76", "doda_pedimentos"), + ("a76", "driver"), + ("a76", "electronic_notices"), + ("a76", "equivalencies"), + ("a76", "equivalency_items"), + ("a76", "error_catalogs"), + ("a76", "error_classifications"), + ("a76", "exchange_rate"), + ("a76", "fa_location_ext"), + ("a76", "fda_affirmation_codes"), + ("a76", "fda_catalog"), + ("a76", "fda_constituent_elements"), + ("a76", "fda_lot_production"), + ("a76", "fda_specifications"), + ("a76", "fraction_rule_octave"), + ("a76", "historical_tariff_fractions"), + ("a76", "identifier_details"), + ("a76", "identifiers"), + ("a76", "inpc"), + ("a76", "invoice_collections"), + ("a76", "invoice_compliance_mx"), + ("a76", "invoice_financials"), + ("a76", "invoice_header"), + ("a76", "invoice_logistics"), + ("a76", "invoice_sales_details"), + ("a76", "invoice_settings"), + ("a76", "item_line_series"), + ("a76", "item_lines"), + ("a76", "item_presets"), + ("a76", "legends"), + ("a76", "location"), + ("a76", "manifest_anexos"), + ("a76", "manifest_drivers"), + ("a76", "manifests"), + ("a76", "multi_currency_types"), + ("a76", "octave_balance"), + ("a76", "packages"), + ("a76", "packing_lists"), + ("a76", "parts"), + ("a76", "pedimento_config_additional"), + ("a76", "pedimento_config_calculations"), + ("a76", "pedimento_config_parameters"), + ("a76", "pedimento_config_surcharges"), + ("a76", "pedimento_config_update_rectification"), + ("a76", "pedimento_config_updates"), + ("a76", "pedimento_containers"), + ("a76", "pedimento_contributions"), + ("a76", "pedimento_customs_offices"), + ("a76", "pedimento_dates"), + ("a76", "pedimento_decrementables"), + ("a76", "pedimento_guides"), + ("a76", "pedimento_incrementables"), + ("a76", "pedimento_indexes"), + ("a76", "pedimento_packages"), + ("a76", "pedimento_payments"), + ("a76", "pedimento_rectification_destination"), + ("a76", "pedimento_rectification_origin"), + ("a76", "pedimento_seals"), + ("a76", "pedimento_transport_carriers"), + ("a76", "pedimento_transport_means"), + ("a76", "pedimento_validation"), + ("a76", "pedimentos"), + ("a76", "permission_rule_oct"), + ("a76", "permission_rule_octave"), + ("a76", "ports"), + ("a76", "prevalidators"), + ("a76", "previous_fractions"), + ("a76", "seal"), + ("a76", "sectors"), + ("a76", "signatures"), + ("a76", "subassembly_entries"), + ("a76", "trailer"), + ("a76", "transporter"), + ("a76", "unit_conversions"), + ("a76", "units_of_measure"), + ("a76", "units_of_measure_general"), + ("a76", "us_tariff_fractions"), + ("a76", "value_manifestations"), + ("a76", "vehicle"), + ("core", "company_roles"), + ("core", "role_permissions"), + ("core", "user_company_permissions"), + ("core", "user_company_roles"), + ("public", "warning_fractions"), +] + +_TABLES_COMPANY_ONLY: list[tuple[str, str]] = [ + ("a24", "inv_aphis_catalog"), + ("a76", "company_address"), + ("a76", "company_certification"), + ("a76", "company_cfdi"), + ("a76", "company_digital_certificate"), + ("a76", "company_electronic_agent"), + ("a76", "company_prevalidator"), +] + +_POLICY_TENANT_ONLY = "tenant_isolation" +_POLICY_TENANT_COMPANY = "tenant_company_isolation" +_POLICY_COMPANY_ONLY = "company_isolation" + + +def _enable_rls_tenant_company() -> None: + op.execute("CREATE SCHEMA IF NOT EXISTS app") + op.execute( + """ + CREATE OR REPLACE FUNCTION app.current_tenant_id() RETURNS INTEGER + LANGUAGE sql STABLE AS $$ + SELECT NULLIF(current_setting('app.tenant_id', true), '')::INTEGER + $$ + """ + ) + op.execute( + """ + CREATE OR REPLACE FUNCTION app.current_company_id() RETURNS INTEGER + LANGUAGE sql STABLE AS $$ + SELECT NULLIF(current_setting('app.company_id', true), '')::INTEGER + $$ + """ + ) + for schema, table in _TABLES_TENANT_ONLY: + op.execute(f'ALTER TABLE "{schema}"."{table}" ENABLE ROW LEVEL SECURITY') + op.execute(f'ALTER TABLE "{schema}"."{table}" FORCE ROW LEVEL SECURITY') + op.execute( + f""" + CREATE POLICY {_POLICY_TENANT_ONLY} ON "{schema}"."{table}" + USING (tenant_id = app.current_tenant_id()) + WITH CHECK (tenant_id = app.current_tenant_id()) + """ + ) + for schema, table in _TABLES_TENANT_AND_COMPANY: + op.execute(f'ALTER TABLE "{schema}"."{table}" ENABLE ROW LEVEL SECURITY') + op.execute(f'ALTER TABLE "{schema}"."{table}" FORCE ROW LEVEL SECURITY') + op.execute( + f""" + CREATE POLICY {_POLICY_TENANT_COMPANY} ON "{schema}"."{table}" + USING ( + tenant_id = app.current_tenant_id() + AND ( + app.current_company_id() IS NULL + OR company_id = app.current_company_id() + ) + ) + WITH CHECK ( + tenant_id = app.current_tenant_id() + AND ( + app.current_company_id() IS NULL + OR company_id = app.current_company_id() + ) + ) + """ + ) + for schema, table in _TABLES_COMPANY_ONLY: + op.execute(f'ALTER TABLE "{schema}"."{table}" ENABLE ROW LEVEL SECURITY') + op.execute(f'ALTER TABLE "{schema}"."{table}" FORCE ROW LEVEL SECURITY') + op.execute( + f""" + CREATE POLICY {_POLICY_COMPANY_ONLY} ON "{schema}"."{table}" + USING ( + EXISTS ( + SELECT 1 FROM a76.company c + WHERE c.id = "{schema}"."{table}".company_id + AND c.tenant_id = app.current_tenant_id() + ) + AND ( + app.current_company_id() IS NULL + OR company_id = app.current_company_id() + ) + ) + WITH CHECK ( + EXISTS ( + SELECT 1 FROM a76.company c + WHERE c.id = "{schema}"."{table}".company_id + AND c.tenant_id = app.current_tenant_id() + ) + AND ( + app.current_company_id() IS NULL + OR company_id = app.current_company_id() + ) + ) + """ + ) + + +def _disable_rls_tenant_company() -> None: + for schema, table in _TABLES_COMPANY_ONLY: + op.execute( + f'DROP POLICY IF EXISTS {_POLICY_COMPANY_ONLY} ON "{schema}"."{table}"' + ) + op.execute(f'ALTER TABLE "{schema}"."{table}" NO FORCE ROW LEVEL SECURITY') + op.execute(f'ALTER TABLE "{schema}"."{table}" DISABLE ROW LEVEL SECURITY') + for schema, table in _TABLES_TENANT_AND_COMPANY: + op.execute( + f'DROP POLICY IF EXISTS {_POLICY_TENANT_COMPANY} ON "{schema}"."{table}"' + ) + op.execute(f'ALTER TABLE "{schema}"."{table}" NO FORCE ROW LEVEL SECURITY') + op.execute(f'ALTER TABLE "{schema}"."{table}" DISABLE ROW LEVEL SECURITY') + for schema, table in _TABLES_TENANT_ONLY: + op.execute( + f'DROP POLICY IF EXISTS {_POLICY_TENANT_ONLY} ON "{schema}"."{table}"' + ) + op.execute(f'ALTER TABLE "{schema}"."{table}" NO FORCE ROW LEVEL SECURITY') + op.execute(f'ALTER TABLE "{schema}"."{table}" DISABLE ROW LEVEL SECURITY') + op.execute("DROP FUNCTION IF EXISTS app.current_company_id()") + op.execute("DROP FUNCTION IF EXISTS app.current_tenant_id()") + op.execute("DROP SCHEMA IF EXISTS app") + + +def upgrade() -> None: + """Upgrade schema.""" + op.execute("CREATE SCHEMA IF NOT EXISTS a24") + op.execute("CREATE SCHEMA IF NOT EXISTS a76") + op.execute("CREATE SCHEMA IF NOT EXISTS core") + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('inv_aphis_catalog', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('program_code', sa.String(length=10), nullable=True), + sa.Column('processing_code', sa.String(length=10), nullable=True), + sa.Column('aphis_type', sa.String(length=10), nullable=True), + sa.Column('disclaimer', sa.String(length=10), nullable=True), + sa.Column('electronic_image', sa.String(length=50), nullable=True), + sa.Column('confidential', sa.String(length=1), nullable=True), + sa.Column('global_product_id', sa.String(length=100), nullable=True), + sa.Column('intended_use_code', sa.String(length=10), nullable=True), + sa.Column('intended_use_description', sa.String(length=200), nullable=True), + sa.Column('item_type', sa.String(length=20), nullable=True), + sa.Column('product_code', sa.String(length=20), nullable=True), + sa.Column('product_code_2', sa.String(length=20), nullable=True), + sa.Column('product_code_3', sa.String(length=20), nullable=True), + sa.Column('scientific_genus_name', sa.String(length=100), nullable=True), + sa.Column('scientific_species_name', sa.String(length=100), nullable=True), + sa.Column('scientific_sub_species_name', sa.String(length=100), nullable=True), + sa.Column('common_name_specific', sa.String(length=200), nullable=True), + sa.Column('common_name_general', sa.String(length=200), nullable=True), + sa.Column('signed_doc', sa.String(length=100), nullable=True), + sa.Column('signed_doc_date', sa.Date(), nullable=True), + sa.Column('signed_doc_id', sa.String(length=50), nullable=True), + sa.Column('invoice_number', sa.String(length=50), nullable=True), + sa.Column('quantity_1', sa.String(length=50), nullable=True), + sa.Column('quantity_2', sa.String(length=50), nullable=True), + sa.Column('quantity_3', sa.String(length=50), nullable=True), + sa.Column('inspection', sa.String(length=200), nullable=True), + sa.Column('inspection_date', sa.Date(), nullable=True), + sa.Column('inspection_loc_date', sa.Date(), nullable=True), + sa.Column('inspection_location', sa.String(length=200), nullable=True), + sa.Column('country_production', sa.String(length=3), nullable=True), + sa.Column('country_source', sa.String(length=3), nullable=True), + sa.Column('characteristics', sa.JSON(), nullable=True), + sa.Column('pitems', sa.JSON(), nullable=True), + sa.Column('lpcos', sa.JSON(), nullable=True), + sa.Column('entities', sa.JSON(), nullable=True), + sa.Column('containers', sa.JSON(), nullable=True), + sa.Column('routing', sa.JSON(), nullable=True), + sa.PrimaryKeyConstraint('id', name='inv_aphis_catalog_pkey'), + schema='a24' + ) + op.create_index(op.f('ix_a24_inv_aphis_catalog_company_id'), 'inv_aphis_catalog', ['company_id'], unique=False, schema='a24') + op.create_table('tariff_fractions', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('code', sa.String(length=10), nullable=False), + sa.Column('fraction', sa.String(length=15), nullable=False), + sa.Column('description', sa.String(length=1000), nullable=True), + sa.Column('nico', sa.String(length=10), nullable=True), + sa.Column('umt', sa.String(length=10), nullable=True), + sa.Column('adv_impo', sa.String(length=20), nullable=True), + sa.Column('adv_expo', sa.String(length=20), nullable=True), + sa.PrimaryKeyConstraint('id', name='tariff_fractions_pkey'), + schema='a76' + ) + op.create_index(op.f('ix_a76_tariff_fractions_code'), 'tariff_fractions', ['code'], unique=True, schema='a76') + op.create_index(op.f('ix_a76_tariff_fractions_fraction'), 'tariff_fractions', ['fraction'], unique=False, schema='a76') + op.create_table('unit_of_measure_ace', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('code', sa.String(length=4), nullable=False), + sa.Column('description', sa.String(length=49), nullable=True), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('code', name='uq_uom_ace_code'), + schema='a76' + ) + op.create_table('unit_of_measure_american', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('code', sa.String(length=3), nullable=False), + sa.Column('description', sa.String(length=40), nullable=True), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('code', name='uq_uom_american_code'), + schema='a76' + ) + op.create_table('unit_of_measure_customs', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('code', sa.String(length=10), nullable=False), + sa.Column('description', sa.String(length=20), nullable=True), + sa.Column('a76_unit_code', sa.String(length=5), nullable=True), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('code', name='uq_uom_customs_code'), + schema='a76' + ) + op.create_table('unit_of_measure_oma', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('code', sa.String(length=10), nullable=False), + sa.Column('description', sa.String(length=200), nullable=True), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('code', name='uq_uom_oma_code'), + schema='a76' + ) + op.create_table('containers', + sa.Column('key', sa.String(length=3), nullable=False), + sa.Column('description', sa.String(length=500), nullable=False), + sa.PrimaryKeyConstraint('key', name='containers_pkey') + ) + op.create_table('permissions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('code', sa.String(length=100), nullable=False), + sa.Column('description', sa.String(length=255), nullable=True), + sa.Column('module', sa.String(length=50), nullable=False), + sa.Column('action', sa.String(length=50), nullable=False), + sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id'), + schema='core' + ) + op.create_index(op.f('ix_core_permissions_code'), 'permissions', ['code'], unique=True, schema='core') + op.create_index(op.f('ix_core_permissions_id'), 'permissions', ['id'], unique=False, schema='core') + op.create_index(op.f('ix_core_permissions_module'), 'permissions', ['module'], unique=False, schema='core') + op.create_table('tenants', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('slug', sa.String(length=100), nullable=False), + sa.Column('type', sa.Enum('SHARED', 'DEDICATED', name='tenanttype'), server_default='SHARED', nullable=False), + sa.Column('keycloak_realm', sa.String(length=255), nullable=False), + sa.Column('db_config', sa.Text(), nullable=True), + sa.Column('contact_name', sa.String(length=255), nullable=True), + sa.Column('contact_email', sa.String(length=255), nullable=True), + sa.Column('contact_phone', sa.String(length=50), nullable=True), + sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id'), + schema='core' + ) + op.create_index(op.f('ix_core_tenants_id'), 'tenants', ['id'], unique=False, schema='core') + op.create_index(op.f('ix_core_tenants_name'), 'tenants', ['name'], unique=False, schema='core') + op.create_index(op.f('ix_core_tenants_slug'), 'tenants', ['slug'], unique=True, schema='core') + op.create_table('help_articles', + sa.Column('uuid', sa.UUID(), nullable=False), + sa.Column('slug', sa.String(length=255), nullable=False), + sa.Column('title', sa.String(length=255), nullable=False), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('last_editor', sa.String(length=255), nullable=False), + sa.Column('category', sa.String(length=255), nullable=True), + sa.Column('order', sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint('uuid') + ) + op.create_index(op.f('ix_help_articles_slug'), 'help_articles', ['slug'], unique=True) + op.create_index(op.f('ix_help_articles_uuid'), 'help_articles', ['uuid'], unique=False) + op.create_table('agency_tariff_codes', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('tariff_flag_code', sa.String(length=10), nullable=False), + sa.Column('agency_code', sa.String(length=10), nullable=False), + sa.Column('requirement_level', sa.String(length=1), nullable=False), + sa.Column('program_code', sa.String(length=10), nullable=False), + sa.Column('definition', sa.String(length=500), nullable=False), + sa.PrimaryKeyConstraint('id', name='agency_tariff_codes_pkey'), + schema='public' + ) + op.create_index(op.f('ix_public_agency_tariff_codes_agency_code'), 'agency_tariff_codes', ['agency_code'], unique=False, schema='public') + op.create_index(op.f('ix_public_agency_tariff_codes_program_code'), 'agency_tariff_codes', ['program_code'], unique=False, schema='public') + op.create_index(op.f('ix_public_agency_tariff_codes_tariff_flag_code'), 'agency_tariff_codes', ['tariff_flag_code'], unique=False, schema='public') + op.create_table('carta_porte_codes', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('code', sa.String(length=20), nullable=False), + sa.Column('description', sa.String(length=2000), nullable=False), + sa.Column('similar_words', sa.String(length=2000), nullable=True), + sa.Column('is_hazardous', sa.Integer(), nullable=False), + sa.Column('start_date', sa.String(length=20), nullable=True), + sa.Column('end_date', sa.String(length=20), nullable=True), + sa.PrimaryKeyConstraint('id'), + schema='public' + ) + op.create_index(op.f('ix_public_carta_porte_codes_code'), 'carta_porte_codes', ['code'], unique=False, schema='public') + op.create_table('countries', + sa.Column('m3_key', sa.String(length=3), nullable=False), + sa.Column('mex_key', sa.String(length=2), nullable=False), + sa.Column('ame_key', sa.String(length=2), nullable=False), + sa.Column('description_es', sa.String(length=50), nullable=False), + sa.Column('description_en', sa.String(length=50), nullable=False), + sa.PrimaryKeyConstraint('m3_key', name='countries_pkey'), + schema='public' + ) + op.create_index('ak_country_ame', 'countries', ['ame_key'], unique=True, schema='public') + op.create_table('currency_types', + sa.Column('code', sa.String(length=3), nullable=False), + sa.Column('currency_name', sa.String(length=15), nullable=False), + sa.Column('country_description', sa.String(length=50), nullable=False), + sa.PrimaryKeyConstraint('code', name='currency_types_pkey'), + schema='public' + ) + op.create_table('customs_sections', + sa.Column('customs_code', sa.String(length=3), nullable=False), + sa.Column('section_name', sa.String(length=255), nullable=False), + sa.PrimaryKeyConstraint('customs_code', name='customs_code_pkey'), + schema='public' + ) + op.create_table('customs_warehouses', + sa.Column('key', sa.String(length=3), nullable=False), + sa.Column('customs', sa.String(length=100), nullable=False), + sa.Column('fiscalized_warehouse', sa.String(length=1000), nullable=False), + sa.PrimaryKeyConstraint('key', 'customs', name='pk_customs_warehouse'), + schema='public' + ) + op.create_table('identifiers', + sa.Column('key', sa.String(length=10), nullable=False), + sa.Column('description', sa.String(length=2000), nullable=False), + sa.Column('level', sa.String(length=1), nullable=False), + sa.Column('complement', sa.String(length=5000), nullable=False), + sa.PrimaryKeyConstraint('key', name='identifiers_pkey'), + schema='public' + ) + op.create_table('incoterms', + sa.Column('code', sa.String(length=5), nullable=False), + sa.Column('description_es', sa.String(length=256), nullable=False), + sa.Column('description_en', sa.String(length=256), nullable=False), + sa.PrimaryKeyConstraint('code', name='incoterms_pkey'), + schema='public' + ) + op.create_table('invoice_types', + sa.Column('key', sa.String(length=5), nullable=False), + sa.Column('description', sa.String(length=50), nullable=False), + sa.Column('note', sa.String(length=500), nullable=False), + sa.Column('type', sa.String(length=15), nullable=False), + sa.Column('operation', sa.String(length=5), nullable=False), + sa.PrimaryKeyConstraint('key', name='invoice_types_pkey'), + schema='public' + ) + op.create_table('license_exceptions', + sa.Column('key', sa.String(length=10), nullable=False), + sa.Column('description', sa.String(length=500), nullable=False), + sa.PrimaryKeyConstraint('key', name='license_exceptions_pkey'), + schema='public' + ) + op.create_table('material_types', + sa.Column('key', sa.String(length=10), nullable=False), + sa.Column('type', sa.String(length=15), nullable=False), + sa.Column('description', sa.String(length=256), nullable=False), + sa.PrimaryKeyConstraint('key', name='material_types_pkey'), + schema='public' + ) + op.create_table('payment_methods', + sa.Column('key', sa.String(length=2), nullable=False), + sa.Column('description', sa.String(length=100), nullable=False), + sa.PrimaryKeyConstraint('key', name='payment_methods_pkey'), + schema='public' + ) + op.create_table('pedimento_codes', + sa.Column('code', sa.String(length=3), nullable=False), + sa.Column('description', sa.String(length=250), nullable=False), + sa.PrimaryKeyConstraint('code', name='pedimento_codes_pkey'), + schema='public' + ) + op.create_table('pedimento_regimens', + sa.Column('code', sa.String(length=3), nullable=False), + sa.Column('description', sa.String(length=100), nullable=False), + sa.PrimaryKeyConstraint('code', name='pedimento_regimens_pkey'), + schema='public' + ) + op.create_table('pedimento_transport_catalog', + sa.Column('code', sa.String(length=3), nullable=False), + sa.Column('transport_en', sa.String(length=80), nullable=False), + sa.Column('transport_es', sa.String(length=120), nullable=False), + sa.Column('payment_date_code', sa.String(length=1), nullable=False), + sa.CheckConstraint("payment_date_code IN ('E', 'P')", name='pedimento_transport_catalog_payment_date_code_chk'), + sa.PrimaryKeyConstraint('code', name='pedimento_transport_catalog_pkey'), + schema='public' + ) + op.create_table('trailer_type', + sa.Column('trailer_type_key', sa.String(length=2), nullable=False), + sa.Column('description', sa.String(length=100), nullable=True), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('trailer_type_key'), + schema='public' + ) + op.create_table('transport_modes', + sa.Column('key', sa.String(length=3), nullable=False), + sa.Column('name', sa.String(length=30), nullable=False), + sa.PrimaryKeyConstraint('key', name='transport_modes_pkey'), + schema='public' + ) + op.create_table('transport_types', + sa.Column('transport_code', sa.String(length=2), nullable=False), + sa.Column('description', sa.String(length=100), nullable=False), + sa.PrimaryKeyConstraint('transport_code', name='transport_types_pkey'), + schema='public' + ) + op.create_table('valuation_methods', + sa.Column('key', sa.String(length=2), nullable=False), + sa.Column('description', sa.String(length=200), nullable=False), + sa.PrimaryKeyConstraint('key', name='valuation_methods_pkey'), + schema='public' + ) + op.create_table('company', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=256), nullable=True), + sa.Column('rfc', sa.String(length=30), nullable=True), + sa.Column('curp', sa.String(length=19), nullable=True), + sa.Column('main_activity', sa.String(length=80), nullable=True), + sa.Column('program', sa.String(length=7), nullable=True), + sa.Column('program_number', sa.String(length=40), nullable=True), + sa.Column('prosec', sa.Boolean(), server_default='false', nullable=False), + sa.Column('prosec_authorization', sa.String(length=20), nullable=True), + sa.Column('sector1', sa.String(length=150), nullable=True), + sa.Column('sector2', sa.String(length=150), nullable=True), + sa.Column('sector3', sa.String(length=5), nullable=True), + sa.Column('manufacturer_id', sa.String(length=25), nullable=True), + sa.Column('broker_company', sa.String(length=6), nullable=True), + sa.Column('responsible', sa.String(length=80), nullable=True), + sa.Column('responsible_name', sa.String(length=20), nullable=True), + sa.Column('responsible_last_name', sa.String(length=20), nullable=True), + sa.Column('responsible_mother_last_name', sa.String(length=20), nullable=True), + sa.Column('responsible_rfc', sa.String(length=30), nullable=True), + sa.Column('position', sa.String(length=30), nullable=True), + sa.Column('logo', sa.String(length=512), nullable=True), + sa.Column('fiscal_deposit', sa.Boolean(), server_default='false', nullable=True), + sa.Column('generate_barcodes_with_fiel', sa.Boolean(), server_default='false', nullable=True), + sa.Column('order_format_type', sa.String(length=19), nullable=True), + sa.Column('is_service_company', sa.Boolean(), server_default='false', nullable=True), + sa.Column('client_name', sa.String(length=300), nullable=True), + sa.Column('subassembly_mode', sa.String(length=7), nullable=True), + sa.Column('previous_code', sa.SmallInteger(), nullable=True), + sa.Column('active_labels', sa.SmallInteger(), nullable=True), + sa.Column('active_fractions', sa.SmallInteger(), nullable=True), + sa.Column('activate_caat', sa.SmallInteger(), nullable=True), + sa.Column('trans_interface', sa.SmallInteger(), nullable=True), + sa.Column('american_costs', sa.SmallInteger(), nullable=True), + sa.Column('scaf_readonly', sa.SmallInteger(), nullable=True), + sa.Column('parts_replacement', sa.SmallInteger(), nullable=True), + sa.Column('activate_facmexame', sa.SmallInteger(), nullable=True), + sa.Column('part_reference', sa.SmallInteger(), nullable=True), + sa.Column('international_firm', sa.SmallInteger(), nullable=True), + sa.Column('seventh_amendment', sa.Boolean(), nullable=True), + sa.Column('ftp_key', sa.String(length=10), nullable=True), + sa.Column('sifra_path', sa.String(length=255), nullable=True), + sa.Column('version_type', sa.String(length=20), nullable=True), + sa.Column('sql_language', sa.String(length=19), nullable=True), + sa.Column('balance_operation_mode', sa.String(length=50), nullable=True), + sa.Column('inter_db_name', sa.String(length=100), nullable=True), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + schema='a76' + ) + op.create_index(op.f('ix_a76_company_tenant_id'), 'company', ['tenant_id'], unique=False, schema='a76') + op.create_table('customs_broker_concepts', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('broker_key', sa.String(length=5), nullable=False), + sa.Column('concept', sa.String(length=15), nullable=False), + sa.Column('amount', sa.Numeric(precision=11, scale=2), nullable=True), + sa.Column('priority', sa.Integer(), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('broker_key', 'concept', 'company_id', name='uq_broker_concept'), + schema='a76' + ) + op.create_index(op.f('ix_a76_customs_broker_concepts_tenant_id'), 'customs_broker_concepts', ['tenant_id'], unique=False, schema='a76') + op.create_table('license_usage', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('period_start', sa.DateTime(timezone=True), nullable=False), + sa.Column('period_end', sa.DateTime(timezone=True), nullable=False), + sa.Column('active_users', sa.Integer(), server_default='0', nullable=True), + sa.Column('storage_used_gb', sa.Integer(), server_default='0', nullable=True), + sa.Column('operations_count', sa.Integer(), server_default='0', nullable=True), + sa.Column('api_calls_count', sa.Integer(), server_default='0', nullable=True), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + schema='core' + ) + op.create_index(op.f('ix_core_license_usage_id'), 'license_usage', ['id'], unique=False, schema='core') + op.create_index(op.f('ix_core_license_usage_tenant_id'), 'license_usage', ['tenant_id'], unique=False, schema='core') + op.create_table('licenses', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('plan', sa.Enum('FREE', 'BASIC', 'PROFESSIONAL', 'ENTERPRISE', name='licenseplan'), server_default='FREE', nullable=False), + sa.Column('status', sa.Enum('ACTIVE', 'EXPIRED', 'SUSPENDED', 'PENDING', 'CANCELLED', name='licensestatus'), server_default='PENDING', nullable=False), + sa.Column('max_users', sa.Integer(), server_default='5', nullable=False), + sa.Column('max_storage_gb', sa.Integer(), server_default='10', nullable=False), + sa.Column('max_monthly_operations', sa.Integer(), server_default='1000', nullable=False), + sa.Column('feature_api_access', sa.Boolean(), server_default='true', nullable=True), + sa.Column('feature_advanced_reports', sa.Boolean(), server_default='false', nullable=True), + sa.Column('feature_integrations', sa.Boolean(), server_default='false', nullable=True), + sa.Column('feature_dedicated_support', sa.Boolean(), server_default='false', nullable=True), + sa.Column('starts_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + schema='core' + ) + op.create_index(op.f('ix_core_licenses_id'), 'licenses', ['id'], unique=False, schema='core') + op.create_index(op.f('ix_core_licenses_tenant_id'), 'licenses', ['tenant_id'], unique=True, schema='core') + op.create_table('code_pedimento_regimens', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('pedimento_code', sa.String(length=3), nullable=False), + sa.Column('regimen_code', sa.String(length=3), nullable=False), + sa.Column('type_code', sa.String(length=1), nullable=True), + sa.ForeignKeyConstraint(['pedimento_code'], ['public.pedimento_codes.code'], name='fk_codeped'), + sa.ForeignKeyConstraint(['regimen_code'], ['public.pedimento_regimens.code'], name='fk_regimenped'), + sa.PrimaryKeyConstraint('id', name='clave_pedimento_regimens_pkey'), + schema='public' + ) + op.create_table('states', + sa.Column('m3_key', sa.String(length=3), nullable=False), + sa.Column('description', sa.String(length=50), nullable=False), + sa.Column('mex_key', sa.String(length=3), nullable=True), + sa.ForeignKeyConstraint(['m3_key'], ['public.countries.m3_key'], ), + sa.PrimaryKeyConstraint('m3_key', 'description', name='states_pkey'), + schema='public' + ) + op.create_table('CompanyVU', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('webservice_user', sa.String(length=100), nullable=True), + sa.Column('webservice_password', sa.String(length=100), nullable=True), + sa.Column('email', sa.String(length=800), nullable=True), + sa.Column('figure_type', sa.String(length=29), nullable=True), + sa.Column('central_path', sa.String(length=1499), nullable=True), + sa.Column('xml_files_path', sa.String(length=1499), nullable=True), + sa.Column('query_rfc', sa.String(length=30), nullable=True), + sa.Column('validation_rfc', sa.String(length=30), nullable=True), + sa.Column('configuration_source', sa.String(length=30), nullable=True), + sa.Column('measurement_units', sa.String(length=3), nullable=True), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + schema='a76' + ) + op.create_index(op.f('ix_a76_CompanyVU_company_id'), 'CompanyVU', ['company_id'], unique=True, schema='a76') + op.create_table('app_settings', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('tenant_id', sa.Integer(), nullable=True), + sa.Column('company_id', sa.Integer(), nullable=True), + sa.Column('settings', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('tenant_id', 'company_id', name='uq_app_settings_tenant_company'), + schema='a76' + ) + op.create_index(op.f('ix_a76_app_settings_company_id'), 'app_settings', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_app_settings_tenant_id'), 'app_settings', ['tenant_id'], unique=False, schema='a76') + op.create_table('audit_logs', + sa.Column('spec_id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('reference', sa.String(length=100), nullable=False), + sa.Column('procedure', sa.String(length=100), nullable=False), + sa.Column('movement', sa.String(length=255), nullable=False), + sa.Column('username', sa.String(length=100), nullable=False), + sa.Column('date', sa.Date(), nullable=False), + sa.Column('time', sa.Time(), nullable=False), + sa.Column('timestamp', sa.DateTime(timezone=True), nullable=False), + sa.Column('system', sa.String(length=50), nullable=False), + sa.Column('table_name', sa.String(length=100), nullable=True), + sa.Column('record_id', sa.String(length=255), nullable=True), + sa.Column('operation_type', sa.String(length=50), nullable=True), + sa.Column('old_values', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('new_values', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('changed_fields', postgresql.ARRAY(sa.String()), nullable=True), + sa.Column('ip_address', sa.String(length=45), nullable=True), + sa.Column('user_agent', sa.Text(), nullable=True), + sa.Column('endpoint', sa.String(length=500), nullable=True), + sa.Column('request_method', sa.String(length=10), nullable=True), + sa.Column('session_id', sa.String(length=50), nullable=True), + sa.Column('execution_time_ms', sa.Integer(), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('spec_id'), + schema='a76' + ) + op.create_index('idx_audit_procedure_date', 'audit_logs', ['procedure', 'date'], unique=False, schema='a76') + op.create_index('idx_audit_system_timestamp', 'audit_logs', ['system', 'timestamp'], unique=False, schema='a76') + op.create_index('idx_audit_table_record', 'audit_logs', ['table_name', 'record_id'], unique=False, schema='a76') + op.create_index('idx_audit_username_date', 'audit_logs', ['username', 'date'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_audit_logs_company_id'), 'audit_logs', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_audit_logs_date'), 'audit_logs', ['date'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_audit_logs_operation_type'), 'audit_logs', ['operation_type'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_audit_logs_procedure'), 'audit_logs', ['procedure'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_audit_logs_record_id'), 'audit_logs', ['record_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_audit_logs_reference'), 'audit_logs', ['reference'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_audit_logs_session_id'), 'audit_logs', ['session_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_audit_logs_system'), 'audit_logs', ['system'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_audit_logs_table_name'), 'audit_logs', ['table_name'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_audit_logs_tenant_id'), 'audit_logs', ['tenant_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_audit_logs_timestamp'), 'audit_logs', ['timestamp'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_audit_logs_username'), 'audit_logs', ['username'], unique=False, schema='a76') + op.create_table('canadian_tariff_fractions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('fraction', sa.String(length=13), nullable=False), + sa.Column('ad_valorem', sa.Numeric(precision=5, scale=2), nullable=True), + sa.Column('unit_of_measure', sa.String(length=5), nullable=True), + sa.Column('country_code', sa.String(length=3), nullable=False), + sa.Column('description', sa.String(length=1000), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('fraction', 'country_code', 'company_id', name='uq_canadian_fraction_country_company'), + schema='a76' + ) + op.create_index(op.f('ix_a76_canadian_tariff_fractions_company_id'), 'canadian_tariff_fractions', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_canadian_tariff_fractions_country_code'), 'canadian_tariff_fractions', ['country_code'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_canadian_tariff_fractions_fraction'), 'canadian_tariff_fractions', ['fraction'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_canadian_tariff_fractions_id'), 'canadian_tariff_fractions', ['id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_canadian_tariff_fractions_tenant_id'), 'canadian_tariff_fractions', ['tenant_id'], unique=False, schema='a76') + op.create_table('classification_concepts', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('classification', sa.String(length=30), nullable=False), + sa.Column('description', sa.String(length=255), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('classification', name='uq_classification_concept'), + schema='a76' + ) + op.create_index(op.f('ix_a76_classification_concepts_company_id'), 'classification_concepts', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_classification_concepts_tenant_id'), 'classification_concepts', ['tenant_id'], unique=False, schema='a76') + op.create_table('clients_and_providers', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('type_nat_foreign', sa.String(length=1), nullable=True), + sa.Column('name', sa.String(length=256), nullable=True), + sa.Column('short_name', sa.String(length=10), nullable=True), + sa.Column('rfc', sa.String(length=30), nullable=True), + sa.Column('curp', sa.String(length=19), nullable=True), + sa.Column('client_or_provider', sa.Enum('CLIENT', 'PROVIDER', 'BOTH', name='entity_client_or_provider'), nullable=False), + sa.Column('linking', sa.String(length=1), nullable=True), + sa.Column('transform_subassembly', sa.String(length=1), nullable=True), + sa.Column('extra_information', sa.String(length=399), nullable=True), + sa.Column('web_key', sa.String(length=40), nullable=True), + sa.Column('responsible', sa.String(length=80), nullable=True), + sa.Column('position', sa.String(length=30), nullable=True), + sa.Column('incoterm', sa.String(length=19), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='clients_and_providers_pkey'), + schema='a76' + ) + op.create_index(op.f('ix_a76_clients_and_providers_company_id'), 'clients_and_providers', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_clients_and_providers_tenant_id'), 'clients_and_providers', ['tenant_id'], unique=False, schema='a76') + op.create_table('company_address', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('address_type', sa.String(length=20), nullable=False), + sa.Column('street', sa.String(length=100), nullable=True), + sa.Column('exterior_number', sa.String(length=20), nullable=True), + sa.Column('interior_number', sa.String(length=20), nullable=True), + sa.Column('postal_code', sa.String(length=15), nullable=True), + sa.Column('neighborhood', sa.String(length=40), nullable=True), + sa.Column('city', sa.String(length=40), nullable=True), + sa.Column('municipality', sa.String(length=50), nullable=True), + sa.Column('state', sa.String(length=30), nullable=True), + sa.Column('country', sa.String(length=4), nullable=True), + sa.Column('phone', sa.String(length=30), nullable=True), + sa.Column('fax', sa.String(length=30), nullable=True), + sa.Column('email', sa.String(length=100), nullable=True), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + schema='a76' + ) + op.create_index(op.f('ix_a76_company_address_company_id'), 'company_address', ['company_id'], unique=False, schema='a76') + op.create_table('company_certification', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('is_certified_company', sa.Boolean(), nullable=True), + sa.Column('certified_company_registration', sa.String(length=40), nullable=True), + sa.Column('certified_company_start_date', sa.Date(), nullable=True), + sa.Column('certified_company_end_date', sa.Date(), nullable=True), + sa.Column('annex30_certification_date', sa.Date(), nullable=True), + sa.Column('annex30_certification_number', sa.String(length=50), nullable=True), + sa.Column('annex30_modality', sa.String(length=3), nullable=True), + sa.Column('annex30_company_type', sa.String(length=50), nullable=True), + sa.Column('annex30_renewal_date', sa.Date(), nullable=True), + sa.Column('annex30_final_certification_date', sa.Date(), nullable=True), + sa.Column('is_seciit_company', sa.Boolean(), nullable=True), + sa.Column('is_oea_company', sa.Boolean(), nullable=True), + sa.Column('ctpat_svi', sa.String(length=100), nullable=True), + sa.Column('trusted_exporter_number', sa.String(length=50), nullable=True), + sa.Column('neec_company', sa.Boolean(), nullable=True), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + schema='a76' + ) + op.create_index(op.f('ix_a76_company_certification_company_id'), 'company_certification', ['company_id'], unique=True, schema='a76') + op.create_table('company_cfdi', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('xml_save_path', sa.String(length=5000), nullable=True), + sa.Column('cfdi_app_path', sa.String(length=5000), nullable=True), + sa.Column('pac_app_path', sa.String(length=5000), nullable=True), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + schema='a76' + ) + op.create_index(op.f('ix_a76_company_cfdi_company_id'), 'company_cfdi', ['company_id'], unique=True, schema='a76') + op.create_table('company_digital_certificate', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('certificate_type', sa.String(length=20), nullable=False), + sa.Column('cer_file_path', sa.String(length=5000), nullable=True), + sa.Column('key_file_path', sa.String(length=5000), nullable=True), + sa.Column('password', sa.String(length=200), nullable=True), + sa.Column('access_key', sa.String(length=50), nullable=True), + sa.Column('cer_expiration_date', sa.Integer(), nullable=True), + sa.Column('key_expiration_date', sa.Integer(), nullable=True), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + schema='a76' + ) + op.create_index(op.f('ix_a76_company_digital_certificate_company_id'), 'company_digital_certificate', ['company_id'], unique=False, schema='a76') + op.create_table('company_electronic_agent', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('input_folder', sa.String(length=1000), nullable=True), + sa.Column('output_folder', sa.String(length=1000), nullable=True), + sa.Column('send_mask', sa.String(length=20), nullable=True), + sa.Column('response_mask', sa.String(length=20), nullable=True), + sa.Column('response_extension', sa.String(length=20), nullable=True), + sa.Column('counter_start', sa.Integer(), nullable=True), + sa.Column('counter_end', sa.Integer(), nullable=True), + sa.Column('counter_next', sa.Integer(), nullable=True), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + schema='a76' + ) + op.create_index(op.f('ix_a76_company_electronic_agent_company_id'), 'company_electronic_agent', ['company_id'], unique=True, schema='a76') + op.create_table('company_prevalidator', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('customs', sa.String(length=20), nullable=True), + sa.Column('key', sa.String(length=20), nullable=True), + sa.Column('patent', sa.String(length=4), nullable=True), + sa.Column('description', sa.String(length=100), nullable=True), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + schema='a76' + ) + op.create_index(op.f('ix_a76_company_prevalidator_company_id'), 'company_prevalidator', ['company_id'], unique=True, schema='a76') + op.create_table('customs_brokers', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('type', sa.String(length=9), nullable=True), + sa.Column('broker_key', sa.String(length=5), nullable=False), + sa.Column('name', sa.String(length=80), nullable=True), + sa.Column('address', sa.String(length=1500), nullable=True), + sa.Column('postal_code', sa.String(length=15), nullable=True), + sa.Column('city', sa.String(length=30), nullable=True), + sa.Column('state', sa.String(length=30), nullable=True), + sa.Column('phone', sa.String(length=30), nullable=True), + sa.Column('fax', sa.String(length=30), nullable=True), + sa.Column('email', sa.String(length=100), nullable=True), + sa.Column('country', sa.String(length=3), nullable=True), + sa.Column('tax_id', sa.String(length=30), nullable=True), + sa.Column('personal_id', sa.String(length=20), nullable=True), + sa.Column('position', sa.String(length=30), nullable=True), + sa.Column('license', sa.String(length=4), nullable=True), + sa.Column('company', sa.String(length=200), nullable=True), + sa.Column('contact', sa.String(length=80), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='customs_brokers_pkey'), + sa.UniqueConstraint('broker_key', 'tenant_id', 'company_id', name='uq_broker_key_tenant_company'), + schema='a76' + ) + op.create_index(op.f('ix_a76_customs_brokers_company_id'), 'customs_brokers', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_customs_brokers_tenant_id'), 'customs_brokers', ['tenant_id'], unique=False, schema='a76') + op.create_table('depreciation_catalog', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('fraction', sa.String(length=10), nullable=False), + sa.Column('description', sa.String(length=500), nullable=False), + sa.Column('depreciation_rate', sa.Numeric(precision=5, scale=2), nullable=False), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='depreciation_catalog_pkey'), + schema='a76' + ) + op.create_index(op.f('ix_a76_depreciation_catalog_company_id'), 'depreciation_catalog', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_depreciation_catalog_description'), 'depreciation_catalog', ['description'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_depreciation_catalog_fraction'), 'depreciation_catalog', ['fraction'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_depreciation_catalog_tenant_id'), 'depreciation_catalog', ['tenant_id'], unique=False, schema='a76') + op.create_table('document_types_digitization', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('code', sa.String(length=10), nullable=False), + sa.Column('description', sa.Text(), nullable=False), + sa.Column('active', sa.Boolean(), server_default='true', nullable=False), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='document_types_digitization_pkey'), + sa.UniqueConstraint('tenant_id', 'company_id', 'code', name='document_types_digitization_code_key'), + schema='a76' + ) + op.create_index(op.f('ix_a76_document_types_digitization_code'), 'document_types_digitization', ['code'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_document_types_digitization_company_id'), 'document_types_digitization', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_document_types_digitization_tenant_id'), 'document_types_digitization', ['tenant_id'], unique=False, schema='a76') + op.create_table('doda', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('integration_number', sa.String(length=30), nullable=True), + sa.Column('doda_date', sa.Integer(), nullable=True), + sa.Column('doda_time', sa.Integer(), nullable=True), + sa.Column('dispatch_customs', sa.String(length=3), nullable=True), + sa.Column('customs_sections', sa.String(length=3), nullable=True), + sa.Column('patent', sa.String(length=4), nullable=True), + sa.Column('pedimentos', sa.String(length=80), nullable=True), + sa.Column('caat', sa.String(length=10), nullable=True), + sa.Column('transport_identification', sa.String(length=20), nullable=True), + sa.Column('fast_id', sa.String(length=20), nullable=True), + sa.Column('operation_type', sa.String(length=1), nullable=True), + sa.Column('selected', sa.Boolean(), nullable=True), + sa.Column('user_selected', sa.String(length=30), nullable=True), + sa.Column('last_user', sa.String(length=30), nullable=True), + sa.Column('responsible', sa.String(length=14), nullable=True), + sa.Column('carrier', sa.String(length=8), nullable=True), + sa.Column('shipments', sa.String(length=80), nullable=True), + sa.Column('pedimento_type', sa.String(length=30), nullable=True), + sa.Column('original_chain', sa.String(length=5000), nullable=True), + sa.Column('serial_number', sa.String(length=21), nullable=True), + sa.Column('electronic_signature', sa.String(length=2000), nullable=True), + sa.Column('transaction_number', sa.String(length=30), nullable=True), + sa.Column('status', sa.String(length=30), nullable=True), + sa.Column('linq_sat_qr', sa.String(length=1000), nullable=True), + sa.Column('sat_certificate', sa.String(length=2001), nullable=True), + sa.Column('sat_digital_seal', sa.Text(), nullable=True), + sa.Column('xml_doda_sent_path', sa.String(length=1000), nullable=True), + sa.Column('xml_doda_response_path', sa.String(length=1000), nullable=True), + sa.Column('doda_report_pdf_path', sa.String(length=1000), nullable=True), + sa.Column('doda_report_pdf_generated_at', sa.DateTime(), nullable=True), + sa.Column('doda_report_source_fingerprint', sa.String(length=64), nullable=True), + sa.Column('sat_original_chain', sa.Text(), nullable=True), + sa.Column('customs_clearance', sa.Integer(), nullable=True), + sa.Column('unique_badge_number', sa.String(length=250), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='doda_pkey'), + schema='a76' + ) + op.create_index(op.f('ix_a76_doda_company_id'), 'doda', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_doda_tenant_id'), 'doda', ['tenant_id'], unique=False, schema='a76') + op.create_table( + "doda_alta_log", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("doda_id", sa.Integer(), nullable=True), + sa.Column("variant", sa.String(length=10), nullable=True), + sa.Column("action", sa.String(length=20), nullable=True), + sa.Column("responsible", sa.String(length=20), nullable=True), + sa.Column("patent", sa.String(length=10), nullable=True), + sa.Column("dispatch_customs", sa.String(length=10), nullable=True), + sa.Column("operation_type", sa.String(length=5), nullable=True), + sa.Column("integration_number", sa.String(length=50), nullable=True), + sa.Column("task_id", sa.String(length=255), nullable=True), + sa.Column("status", sa.String(length=30), nullable=True), + sa.Column("message", sa.String(length=2000), nullable=True), + sa.Column("result_json", sa.Text(), nullable=True), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("company_id", sa.Integer(), nullable=False), + sa.Column("deleted_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["company_id"], ["a76.company.id"]), + sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]), + sa.PrimaryKeyConstraint("id", name="doda_alta_log_pkey"), + schema="a76", + ) + op.create_index( + op.f("ix_a76_doda_alta_log_company_id"), + "doda_alta_log", + ["company_id"], + unique=False, + schema="a76", + ) + op.create_index( + op.f("ix_a76_doda_alta_log_tenant_id"), + "doda_alta_log", + ["tenant_id"], + unique=False, + schema="a76", + ) + op.create_index( + op.f("ix_a76_doda_alta_log_doda_id"), + "doda_alta_log", + ["doda_id"], + unique=False, + schema="a76", + ) + op.create_index( + op.f("ix_a76_doda_alta_log_task_id"), + "doda_alta_log", + ["task_id"], + unique=False, + schema="a76", + ) + op.create_table('electronic_notices', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('notice_number', sa.String(length=500), nullable=True), + sa.Column('year', sa.String(length=20), nullable=True), + sa.Column('patent', sa.String(length=4), nullable=True), + sa.Column('pedimento', sa.String(length=15), nullable=True), + sa.Column('file_sent', sa.String(length=1000), nullable=True), + sa.Column('file_response', sa.String(length=1000), nullable=True), + sa.Column('status', sa.String(length=100), nullable=True), + sa.Column('invoice', sa.String(length=50), nullable=True), + sa.Column('validation_acknowledgment', sa.String(length=20), nullable=True), + sa.Column('fea', sa.String(length=1000), nullable=True), + sa.Column('certificate_number', sa.String(length=50), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='electronic_notices_pkey'), + schema='a76' + ) + op.create_index(op.f('ix_a76_electronic_notices_company_id'), 'electronic_notices', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_electronic_notices_tenant_id'), 'electronic_notices', ['tenant_id'], unique=False, schema='a76') + op.create_table('equivalency_items', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('original_field', sa.String(length=100), nullable=False), + sa.Column('external_field', sa.String(length=100), nullable=False), + sa.Column('conversion_factor', sa.Numeric(precision=13, scale=6), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('original_field', 'external_field', 'tenant_id', 'company_id', name='uq_equivalency_item_fields'), + schema='a76' + ) + op.create_index(op.f('ix_a76_equivalency_items_company_id'), 'equivalency_items', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_equivalency_items_tenant_id'), 'equivalency_items', ['tenant_id'], unique=False, schema='a76') + op.create_table('error_classifications', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('code', sa.String(length=100), nullable=False), + sa.Column('level', sa.String(length=3), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='error_classifications_pkey'), + sa.UniqueConstraint('code'), + sa.UniqueConstraint('code', name='error_classifications_code_unique'), + schema='a76' + ) + op.create_index(op.f('ix_a76_error_classifications_company_id'), 'error_classifications', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_error_classifications_tenant_id'), 'error_classifications', ['tenant_id'], unique=False, schema='a76') + op.create_table('exchange_rate', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('date', sa.DateTime(), nullable=False), + sa.Column('value', sa.DECIMAL(precision=13, scale=6), nullable=True), + sa.Column('local_currency', sa.String(length=7), nullable=True), + sa.Column('foreign_currency', sa.String(length=7), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='exchange_rate_pkey'), + sa.UniqueConstraint('tenant_id', 'company_id', 'date', name='uq_exchange_rate_date_tenant'), + schema='a76' + ) + op.create_index(op.f('ix_a76_exchange_rate_company_id'), 'exchange_rate', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_exchange_rate_tenant_id'), 'exchange_rate', ['tenant_id'], unique=False, schema='a76') + op.create_table('expediente_archivo', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('e_document', sa.String(length=50), nullable=True), + sa.Column('num_operacion', sa.String(length=50), nullable=True), + sa.Column('tipo_documento', sa.String(length=10), nullable=True), + sa.Column('archivo_digitalizado_en', sa.String(length=500), nullable=True), + sa.Column('fecha_digitalizacion', sa.Date(), nullable=True), + sa.Column('agente_aduanal', sa.String(length=50), nullable=True), + sa.Column('pedimento', sa.String(length=21), nullable=True), + sa.Column('rfc_consulta', sa.String(length=13), nullable=True), + sa.Column('nombre_archivo', sa.String(length=255), nullable=True), + sa.Column('status', sa.String(length=20), nullable=True), + sa.Column('task_id', sa.String(length=255), nullable=True), + sa.Column('external_task_id', sa.String(length=255), nullable=True), + sa.Column('acuse_pdf_path', sa.String(length=500), nullable=True), + sa.Column('envio_xml_path', sa.String(length=500), nullable=True), + sa.Column('respuesta_xml_path', sa.String(length=500), nullable=True), + sa.Column('consulta_envio_xml_path', sa.String(length=500), nullable=True), + sa.Column('consulta_respuesta_xml_path', sa.String(length=500), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + schema='a76' + ) + op.create_index(op.f('ix_a76_expediente_archivo_company_id'), 'expediente_archivo', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_expediente_archivo_external_task_id'), 'expediente_archivo', ['external_task_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_expediente_archivo_task_id'), 'expediente_archivo', ['task_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_expediente_archivo_tenant_id'), 'expediente_archivo', ['tenant_id'], unique=False, schema='a76') + op.create_table('fda_catalog', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('fda_key', sa.String(length=20), nullable=False), + sa.Column('description', sa.String(length=500), nullable=False), + sa.Column('fda_code', sa.String(length=50), nullable=True), + sa.Column('requirements', sa.String(length=500), nullable=True), + sa.Column('manufacturer_number', sa.String(length=50), nullable=True), + sa.Column('country_of_production', sa.String(length=100), nullable=True), + sa.Column('storage_status', sa.String(length=100), nullable=True), + sa.Column('warehouse_code', sa.String(length=20), nullable=True), + sa.Column('call_atl', sa.String(length=20), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='fda_catalog_pkey'), + sa.UniqueConstraint('tenant_id', 'company_id', 'fda_key', name='idx_fda_catalog_unique'), + schema='a76' + ) + op.create_index(op.f('ix_a76_fda_catalog_company_id'), 'fda_catalog', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_fda_catalog_description'), 'fda_catalog', ['description'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_fda_catalog_fda_key'), 'fda_catalog', ['fda_key'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_fda_catalog_tenant_id'), 'fda_catalog', ['tenant_id'], unique=False, schema='a76') + op.create_table('fraction_rule_octave', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('permission', sa.String(length=20), nullable=False), + sa.Column('line', sa.Integer(), nullable=False), + sa.Column('fraction', sa.String(length=10), nullable=False), + sa.Column('quota_quantity', sa.Numeric(precision=19, scale=8), nullable=True), + sa.Column('quantity_used', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('quota_value', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('value_used', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('unit_cost_me', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('unit_of_measure', sa.String(length=5), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('tenant_id', 'company_id', 'permission', 'line', 'fraction', name='uq_fraction_rule_octave_permission_line_fraction'), + schema='a76' + ) + op.create_index(op.f('ix_a76_fraction_rule_octave_company_id'), 'fraction_rule_octave', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_fraction_rule_octave_tenant_id'), 'fraction_rule_octave', ['tenant_id'], unique=False, schema='a76') + op.create_table('historical_tariff_fractions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('historical_fraction', sa.String(length=8), nullable=True), + sa.Column('nico', sa.String(length=2), nullable=True), + sa.Column('unit_of_measure_code', sa.String(length=10), nullable=True), + sa.Column('country', sa.String(length=3), nullable=True), + sa.Column('fraction_type', sa.String(length=7), nullable=True), + sa.Column('sector', sa.String(length=5), nullable=True), + sa.Column('import_tax_rate', sa.Numeric(precision=7, scale=2), nullable=True), + sa.Column('export_tax_rate', sa.Numeric(precision=7, scale=2), nullable=True), + sa.Column('publication_date', sa.DateTime(), nullable=True), + sa.Column('is_immex', sa.Boolean(), nullable=True), + sa.Column('normal_temporality', sa.Boolean(), nullable=True), + sa.Column('services_temporality', sa.Boolean(), nullable=True), + sa.Column('certified_temporality', sa.Boolean(), nullable=True), + sa.Column('by_log', sa.Boolean(), nullable=True), + sa.Column('end_date', sa.DateTime(), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['country'], ['public.countries.m3_key'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.ForeignKeyConstraint(['unit_of_measure_code'], ['a76.unit_of_measure_customs.code'], ), + sa.PrimaryKeyConstraint('id'), + schema='a76' + ) + op.create_index(op.f('ix_a76_historical_tariff_fractions_company_id'), 'historical_tariff_fractions', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_historical_tariff_fractions_tenant_id'), 'historical_tariff_fractions', ['tenant_id'], unique=False, schema='a76') + op.create_table('identifiers', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('code', sa.String(length=2), nullable=False), + sa.Column('description', sa.String(length=1000), nullable=True), + sa.Column('level', sa.String(length=1), nullable=True), + sa.Column('complement', sa.String(length=5000), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('code', name='uq_identifier_code'), + schema='a76' + ) + op.create_index(op.f('ix_a76_identifiers_company_id'), 'identifiers', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_identifiers_tenant_id'), 'identifiers', ['tenant_id'], unique=False, schema='a76') + op.create_table('inpc', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('year', sa.String(length=4), nullable=False), + sa.Column('month', sa.String(length=2), nullable=False), + sa.Column('value', sa.Numeric(precision=19, scale=8), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('year', 'month', 'tenant_id', 'company_id', name='uq_inpc_year_month'), + schema='a76' + ) + op.create_index(op.f('ix_a76_inpc_company_id'), 'inpc', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_inpc_tenant_id'), 'inpc', ['tenant_id'], unique=False, schema='a76') + op.create_table('invoice_header', + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('system', sa.String(length=12), nullable=False), + sa.Column('operation_type', sa.String(length=11), nullable=False), + sa.Column('invoice_type', sa.String(length=5), nullable=False), + sa.Column('document_type', sa.String(length=3), nullable=True), + sa.Column('invoice_number', sa.String(length=100), nullable=False), + sa.Column('project_number', sa.String(length=14), nullable=True), + sa.Column('purchase_order', sa.String(length=50), nullable=True), + sa.Column('related_doc_id', sa.Integer(), nullable=True), + sa.Column('alternate_invoice', sa.String(length=99), nullable=True), + sa.Column('invoice_ref', sa.String(length=19), nullable=True), + sa.Column('proforma_number', sa.String(length=20), nullable=True), + sa.Column('invoice_date', sa.Date(), nullable=False), + sa.Column('capture_date', sa.TIMESTAMP(), server_default=sa.text('now()'), nullable=False), + sa.Column('emission_date', sa.Date(), nullable=True), + sa.Column('status', sa.String(length=10), nullable=False), + sa.Column('status_rec', sa.String(length=10), nullable=True), + sa.Column('status_rep', sa.String(length=10), nullable=True), + sa.Column('processed_date', sa.TIMESTAMP(), nullable=True), + sa.Column('who_processed', sa.String(length=100), nullable=True), + sa.Column('capture_user', sa.String(length=100), nullable=True), + sa.Column('traffic_light_status', sa.String(length=50), nullable=True), + sa.Column('process_log', sa.String(length=300), nullable=True), + sa.Column('observation_es', sa.Text(), nullable=True), + sa.Column('observation_en', sa.Text(), nullable=True), + sa.Column('comments_status', sa.Text(), nullable=True), + sa.Column('vu_observations', sa.String(length=500), nullable=True), + sa.Column('cfdi_uuid', sa.String(length=100), nullable=True), + sa.Column('path_pdf', sa.String(length=500), nullable=True), + sa.Column('path_xml', sa.String(length=500), nullable=True), + sa.Column('subcompany', sa.String(length=5), nullable=True), + sa.Column('party_count', sa.Integer(), nullable=True), + sa.Column('generate_id', sa.Boolean(), server_default='false', nullable=True), + sa.Column('generate_desc_parties', sa.String(length=12), nullable=True), + sa.Column('apply_manual_discount', sa.Boolean(), server_default='false', nullable=True), + sa.Column('is_bulk', sa.Boolean(), nullable=True), + sa.Column('download_substance', sa.Boolean(), nullable=True), + sa.Column('download_class', sa.Boolean(), nullable=True), + sa.Column('download_def', sa.Boolean(), nullable=True), + sa.Column('payment_terms', sa.String(length=200), nullable=True), + sa.Column('handling_fees', sa.Numeric(precision=19, scale=8), nullable=True), + sa.Column('option_iv18', sa.String(length=50), nullable=True), + sa.Column('enajenation_goods', sa.Boolean(), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['document_type'], ['public.pedimento_regimens.code'], ), + sa.ForeignKeyConstraint(['invoice_type'], ['public.invoice_types.key'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + schema='a76' + ) + op.create_index(op.f('ix_a76_invoice_header_company_id'), 'invoice_header', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_invoice_header_tenant_id'), 'invoice_header', ['tenant_id'], unique=False, schema='a76') + op.create_table('invoice_settings', + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('invoice_type', sa.String(length=5), nullable=False), + sa.Column('operation_type', sa.String(length=11), nullable=False), + sa.Column('settings', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['invoice_type'], ['public.invoice_types.key'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('tenant_id', 'company_id', 'invoice_type', 'operation_type', name='uq_invoice_settings_tenant_company_type_op'), + schema='a76' + ) + op.create_index(op.f('ix_a76_invoice_settings_company_id'), 'invoice_settings', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_invoice_settings_tenant_id'), 'invoice_settings', ['tenant_id'], unique=False, schema='a76') + op.create_table('item_presets', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('description', sa.String(length=500), nullable=True), + sa.Column('items', sa.JSON(), nullable=False), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + schema='a76' + ) + op.create_index(op.f('ix_a76_item_presets_company_id'), 'item_presets', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_item_presets_tenant_id'), 'item_presets', ['tenant_id'], unique=False, schema='a76') + op.create_table('legends', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('code', sa.Integer(), nullable=False), + sa.Column('description', sa.String(length=2000), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('code', 'tenant_id', 'company_id', name='uq_legend_code'), + schema='a76' + ) + op.create_index(op.f('ix_a76_legends_company_id'), 'legends', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_legends_tenant_id'), 'legends', ['tenant_id'], unique=False, schema='a76') + op.create_table('location', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('clave_localizacion', sa.String(length=20), nullable=False), + sa.Column('localizacion', sa.String(length=200), nullable=True), + sa.Column('system', sa.String(length=20), nullable=False), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('clave_localizacion', 'tenant_id', 'company_id', 'system', name='uq_location_clave_tenant_company_system'), + schema='a76' + ) + op.create_index(op.f('ix_a76_location_company_id'), 'location', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_location_tenant_id'), 'location', ['tenant_id'], unique=False, schema='a76') + op.create_table('manifest_anexos', + sa.Column('consecutive', sa.Integer(), nullable=False), + sa.Column('line_number', sa.Integer(), nullable=False), + sa.Column('attachment_type', sa.String(length=10), nullable=True), + sa.Column('number', sa.String(length=10), nullable=True), + sa.Column('attached_doc', sa.String(length=200), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('consecutive', 'line_number', name='manifest_anexos_pkey'), + schema='a76' + ) + op.create_index('idx_manifest_anexos_consecutive', 'manifest_anexos', ['consecutive'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_manifest_anexos_company_id'), 'manifest_anexos', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_manifest_anexos_tenant_id'), 'manifest_anexos', ['tenant_id'], unique=False, schema='a76') + op.create_table('manifest_drivers', + sa.Column('manifest_number', sa.String(length=15), nullable=False), + sa.Column('driver_name', sa.String(length=80), nullable=False), + sa.Column('driver_type', sa.String(length=1), nullable=True), + sa.Column('address_1', sa.String(length=100), nullable=True), + sa.Column('address_2', sa.String(length=100), nullable=True), + sa.Column('city', sa.String(length=30), nullable=True), + sa.Column('state', sa.String(length=30), nullable=True), + sa.Column('postal_code', sa.String(length=15), nullable=True), + sa.Column('country', sa.String(length=3), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('manifest_number', 'driver_name', name='manifest_drivers_pkey'), + schema='a76' + ) + op.create_index('idx_manifest_drivers_manifest_number', 'manifest_drivers', ['manifest_number'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_manifest_drivers_company_id'), 'manifest_drivers', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_manifest_drivers_tenant_id'), 'manifest_drivers', ['tenant_id'], unique=False, schema='a76') + op.create_table('manifests', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('manifest_number', sa.String(length=15), nullable=True), + sa.Column('importer_details', sa.String(length=60), nullable=True), + sa.Column('person_in_charge', sa.String(length=60), nullable=True), + sa.Column('consigned_to', sa.String(length=8), nullable=True), + sa.Column('sent_by', sa.String(length=8), nullable=True), + sa.Column('foreign_exit_port', sa.String(length=6), nullable=True), + sa.Column('foreign_exit_port_loc', sa.String(length=4), nullable=True), + sa.Column('destination_port', sa.String(length=6), nullable=True), + sa.Column('destination_port_loc', sa.String(length=4), nullable=True), + sa.Column('entry_port', sa.String(length=6), nullable=True), + sa.Column('entry_port_loc', sa.String(length=4), nullable=True), + sa.Column('entry_date', sa.Integer(), nullable=True), + sa.Column('net_weight', sa.Numeric(precision=19, scale=8), nullable=True), + sa.Column('gross_weight', sa.Numeric(precision=19, scale=8), nullable=True), + sa.Column('total_value', sa.Numeric(precision=19, scale=8), nullable=True), + sa.Column('description', sa.String(length=2500), nullable=True), + sa.Column('broker_code', sa.String(length=5), nullable=True), + sa.Column('carrier_code', sa.String(length=5), nullable=True), + sa.Column('payment_invoice_number', sa.String(length=5), nullable=True), + sa.Column('seal_number', sa.String(length=30), nullable=True), + sa.Column('entry_hour', sa.Integer(), nullable=True), + sa.Column('hazardous_material', sa.String(length=2), nullable=True), + sa.Column('transport_mode', sa.String(length=2), nullable=True), + sa.Column('transport_code', sa.String(length=14), nullable=True), + sa.Column('trailer_number', sa.String(length=20), nullable=True), + sa.Column('status', sa.String(length=14), nullable=True), + sa.Column('status_description', sa.String(length=1000), nullable=True), + sa.Column('manifest_type', sa.String(length=3), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + schema='a76' + ) + op.create_index('idx_manifests_manifest_number', 'manifests', ['manifest_number'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_manifests_company_id'), 'manifests', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_manifests_tenant_id'), 'manifests', ['tenant_id'], unique=False, schema='a76') + op.create_table('multi_currency_types', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('currency_type_code', sa.String(length=3), nullable=False), + sa.Column('country_key', sa.String(length=3), nullable=True), + sa.Column('conversion_factor', sa.Numeric(precision=13, scale=6), nullable=True), + sa.Column('publication_date', sa.Integer(), nullable=False), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['country_key'], ['public.countries.m3_key'], ), + sa.ForeignKeyConstraint(['currency_type_code'], ['public.currency_types.code'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('currency_type_code', 'publication_date', 'tenant_id', 'company_id', name='uq_multi_currency_type_code_date'), + schema='a76' + ) + op.create_index(op.f('ix_a76_multi_currency_types_company_id'), 'multi_currency_types', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_multi_currency_types_tenant_id'), 'multi_currency_types', ['tenant_id'], unique=False, schema='a76') + op.create_table('octave_balance', + sa.Column('invoice_import', sa.String(length=15), nullable=False), + sa.Column('part_number', sa.String(length=70), nullable=False), + sa.Column('origin_country', sa.String(length=3), nullable=False), + sa.Column('fraction_type', sa.String(length=7), nullable=False), + sa.Column('sector', sa.String(length=8), nullable=False), + sa.Column('octave_permit', sa.String(length=100), nullable=False), + sa.Column('origin', sa.String(length=3), nullable=False), + sa.Column('system', sa.String(length=5), nullable=False), + sa.Column('line', sa.Integer(), nullable=False), + sa.Column('quantity_stock', sa.Numeric(precision=19, scale=8), nullable=True), + sa.Column('unit_of_measure', sa.String(length=5), nullable=True), + sa.Column('class_code', sa.String(length=8), nullable=True), + sa.Column('import_fraction', sa.String(length=10), nullable=True), + sa.Column('ro_fraction', sa.String(length=10), nullable=True), + sa.Column('value_me', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('tenant_id', 'company_id', 'invoice_import', 'part_number', 'origin_country', 'fraction_type', 'sector', 'octave_permit', 'origin', 'system', 'line', name='pk_octave_balance'), + schema='a76' + ) + op.create_index(op.f('ix_a76_octave_balance_company_id'), 'octave_balance', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_octave_balance_tenant_id'), 'octave_balance', ['tenant_id'], unique=False, schema='a76') + op.create_table('packages', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('key', sa.String(length=5), nullable=False), + sa.Column('description_es', sa.String(length=40), nullable=True), + sa.Column('description_en', sa.String(length=40), nullable=True), + sa.Column('weight_unit', sa.DECIMAL(precision=19, scale=8), nullable=True), + sa.Column('plurals', sa.String(length=4), nullable=True), + sa.Column('plural_in', sa.String(length=4), nullable=True), + sa.Column('code_ace', sa.String(length=4), nullable=True), + sa.Column('code_aamex', sa.String(length=9), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='packages_pkey'), + sa.UniqueConstraint('tenant_id', 'company_id', 'key', name='packages_key_ukey'), + schema='a76' + ) + op.create_index(op.f('ix_a76_packages_company_id'), 'packages', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_packages_tenant_id'), 'packages', ['tenant_id'], unique=False, schema='a76') + op.create_table('packing_lists', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('item_line_id', sa.Integer(), nullable=False), + sa.Column('packing_list_number', sa.String(length=100), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + schema='a76' + ) + op.create_index(op.f('ix_a76_packing_lists_company_id'), 'packing_lists', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_packing_lists_tenant_id'), 'packing_lists', ['tenant_id'], unique=False, schema='a76') + op.create_table('permission_rule_oct', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('permission', sa.String(length=20), nullable=False), + sa.Column('start_date', sa.Integer(), nullable=True), + sa.Column('end_date', sa.Integer(), nullable=True), + sa.Column('sector', sa.String(length=8), nullable=True), + sa.Column('system', sa.String(length=5), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='permission_rule_oct_pkey'), + sa.UniqueConstraint('tenant_id', 'company_id', 'permission', name='permission_rule_oct_permission_tenant_ukey'), + schema='a76' + ) + op.create_index(op.f('ix_a76_permission_rule_oct_company_id'), 'permission_rule_oct', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_permission_rule_oct_tenant_id'), 'permission_rule_oct', ['tenant_id'], unique=False, schema='a76') + op.create_table('permission_rule_octave', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('permission', sa.String(length=20), nullable=False), + sa.Column('start_date', sa.DateTime(), nullable=True), + sa.Column('end_date', sa.DateTime(), nullable=True), + sa.Column('sector', sa.String(length=8), nullable=True), + sa.Column('system', sa.String(length=5), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('tenant_id', 'company_id', 'permission', name='uq_permissions_rule_octave_permission'), + schema='a76' + ) + op.create_index(op.f('ix_a76_permission_rule_octave_company_id'), 'permission_rule_octave', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_permission_rule_octave_tenant_id'), 'permission_rule_octave', ['tenant_id'], unique=False, schema='a76') + op.create_table('ports', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('port_code', sa.String(length=6), nullable=False), + sa.Column('description', sa.String(length=20), nullable=True), + sa.Column('location_code', sa.String(length=4), nullable=False), + sa.Column('location_description', sa.String(length=20), nullable=True), + sa.Column('port_type', sa.String(length=15), server_default='ENTRY', nullable=False), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('port_code', 'location_code', 'tenant_id', 'company_id', name='uq_port_location'), + schema='a76' + ) + op.create_index(op.f('ix_a76_ports_company_id'), 'ports', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_ports_tenant_id'), 'ports', ['tenant_id'], unique=False, schema='a76') + op.create_table('prevalidators', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('code', sa.String(length=20), nullable=False), + sa.Column('customs_prevalidator', sa.String(length=20), nullable=True), + sa.Column('patent_prevalidator', sa.String(length=20), nullable=True), + sa.Column('description', sa.String(length=50), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='prevalidators_pkey'), + sa.UniqueConstraint('code', 'tenant_id', 'company_id', name='prevalidators_code_unique'), + schema='a76' + ) + op.create_index(op.f('ix_a76_prevalidators_company_id'), 'prevalidators', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_prevalidators_tenant_id'), 'prevalidators', ['tenant_id'], unique=False, schema='a76') + op.create_table('previous_fractions', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('current_fraction', sa.String(length=50), nullable=True), + sa.Column('previous_fraction', sa.String(length=50), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + schema='a76' + ) + op.create_index(op.f('ix_a76_previous_fractions_company_id'), 'previous_fractions', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_previous_fractions_tenant_id'), 'previous_fractions', ['tenant_id'], unique=False, schema='a76') + op.create_table('seal', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('seal', sa.String(length=15), nullable=False), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='seal_pkey'), + sa.UniqueConstraint('tenant_id', 'company_id', 'seal', name='seal_ukey'), + schema='a76' + ) + op.create_index(op.f('ix_a76_seal_company_id'), 'seal', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_seal_tenant_id'), 'seal', ['tenant_id'], unique=False, schema='a76') + op.create_table('sectors', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('key', sa.String(length=8), nullable=False), + sa.Column('description', sa.String(length=150), nullable=False), + sa.Column('authorized', sa.Boolean(), server_default='false', nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='sectors_pkey'), + sa.UniqueConstraint('tenant_id', 'company_id', 'key', name='sectors_key_ukey'), + schema='a76' + ) + op.create_index(op.f('ix_a76_sectors_company_id'), 'sectors', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_sectors_tenant_id'), 'sectors', ['tenant_id'], unique=False, schema='a76') + op.create_table('signatures', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('code', sa.String(length=10), nullable=False), + sa.Column('signature', sa.String(length=1000), nullable=True), + sa.Column('photo_path', sa.String(length=1000), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='signatures_pkey'), + sa.UniqueConstraint('code', 'tenant_id', 'company_id', name='signatures_code_unique'), + schema='a76' + ) + op.create_index(op.f('ix_a76_signatures_company_id'), 'signatures', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_signatures_tenant_id'), 'signatures', ['tenant_id'], unique=False, schema='a76') + op.create_table('subassembly_entries', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('remission_line', sa.Integer(), nullable=False), + sa.Column('exit_invoice', sa.String(length=15), nullable=True), + sa.Column('exit_line', sa.Integer(), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + schema='a76' + ) + op.create_index(op.f('ix_a76_subassembly_entries_company_id'), 'subassembly_entries', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_subassembly_entries_tenant_id'), 'subassembly_entries', ['tenant_id'], unique=False, schema='a76') + op.create_table('trailer', + sa.Column('trailer_number', sa.String(length=20), nullable=False), + sa.Column('trailer_id', sa.BigInteger(), nullable=False), + sa.Column('ace_trailer_number', sa.String(length=10), nullable=True), + sa.Column('trailer_type_key', sa.String(length=2), nullable=True), + sa.Column('seal', sa.String(length=15), nullable=True), + sa.Column('entity_code', sa.String(length=1), nullable=True), + sa.Column('plate_number', sa.String(length=17), nullable=True), + sa.Column('state', sa.String(length=30), nullable=True), + sa.Column('country', sa.String(length=3), nullable=True), + sa.Column('container_key', sa.String(length=3), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.ForeignKeyConstraint(['trailer_type_key'], ['public.trailer_type.trailer_type_key'], ), + sa.PrimaryKeyConstraint('trailer_number'), + schema='a76' + ) + op.create_index(op.f('ix_a76_trailer_company_id'), 'trailer', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_trailer_tenant_id'), 'trailer', ['tenant_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_trailer_trailer_id'), 'trailer', ['trailer_id'], unique=True, schema='a76') + op.create_table('transporter', + sa.Column('transporter_key', sa.String(length=23), nullable=False), + sa.Column('transporter_id', sa.BigInteger(), nullable=False), + sa.Column('name', sa.String(length=256), nullable=True), + sa.Column('short_name', sa.String(length=10), nullable=True), + sa.Column('responsible', sa.String(length=100), nullable=True), + sa.Column('rfc', sa.String(length=30), nullable=True), + sa.Column('streets', sa.String(length=100), nullable=True), + sa.Column('postal_code', sa.String(length=15), nullable=True), + sa.Column('city', sa.String(length=30), nullable=True), + sa.Column('state', sa.String(length=30), nullable=True), + sa.Column('country', sa.String(length=3), nullable=True), + sa.Column('loader_code', sa.String(length=9), nullable=True), + sa.Column('caat_code', sa.String(length=49), nullable=True), + sa.Column('transport_code', sa.String(length=8), nullable=True), + sa.Column('transport_interface_type', sa.String(length=20), nullable=True), + sa.Column('ftp_server', sa.String(length=200), nullable=True), + sa.Column('ftp_user', sa.String(length=200), nullable=True), + sa.Column('ftp_password', sa.String(length=100), nullable=True), + sa.Column('ftp_directory', sa.String(length=1000), nullable=True), + sa.Column('filler_code', sa.String(length=20), nullable=True), + sa.Column('has_express_line', sa.Boolean(), server_default='false', nullable=False), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('transporter_key'), + schema='a76' + ) + op.create_index(op.f('ix_a76_transporter_company_id'), 'transporter', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_transporter_tenant_id'), 'transporter', ['tenant_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_transporter_transporter_id'), 'transporter', ['transporter_id'], unique=True, schema='a76') + op.create_table('units_of_measure', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('code', sa.String(length=5), nullable=False), + sa.Column('description', sa.String(length=100), nullable=True), + sa.Column('description_en', sa.String(length=100), nullable=True), + sa.Column('customs_code', sa.String(length=10), nullable=True), + sa.Column('american_code', sa.String(length=3), nullable=True), + sa.Column('ace_code', sa.String(length=4), nullable=True), + sa.Column('oma_code', sa.String(length=10), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['ace_code'], ['a76.unit_of_measure_ace.code'], ), + sa.ForeignKeyConstraint(['american_code'], ['a76.unit_of_measure_american.code'], ), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['customs_code'], ['a76.unit_of_measure_customs.code'], ), + sa.ForeignKeyConstraint(['oma_code'], ['a76.unit_of_measure_oma.code'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('code', 'tenant_id', 'company_id', name='uq_uom_code'), + schema='a76' + ) + op.create_index(op.f('ix_a76_units_of_measure_company_id'), 'units_of_measure', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_units_of_measure_tenant_id'), 'units_of_measure', ['tenant_id'], unique=False, schema='a76') + op.create_table('units_of_measure_general', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('code', sa.String(length=5), nullable=False), + sa.Column('description', sa.String(length=100), nullable=True), + sa.Column('conversion_factor', sa.Numeric(precision=13, scale=6), nullable=True), + sa.Column('mexico_unit', sa.String(length=5), nullable=True), + sa.Column('american_unit_code', sa.String(length=3), nullable=True), + sa.Column('customs_code', sa.String(length=10), nullable=True), + sa.Column('ace_code', sa.String(length=4), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['ace_code'], ['a76.unit_of_measure_ace.code'], ), + sa.ForeignKeyConstraint(['ace_code'], ['a76.unit_of_measure_ace.code'], name='fk_uom_general_ace', use_alter=True), + sa.ForeignKeyConstraint(['american_unit_code'], ['a76.unit_of_measure_american.code'], ), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['customs_code'], ['a76.unit_of_measure_customs.code'], ), + sa.ForeignKeyConstraint(['customs_code'], ['a76.unit_of_measure_customs.code'], name='fk_uom_general_customs', use_alter=True), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('code', 'tenant_id', 'company_id', name='uq_uom_general_code'), + schema='a76' + ) + op.create_index(op.f('ix_a76_units_of_measure_general_company_id'), 'units_of_measure_general', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_units_of_measure_general_tenant_id'), 'units_of_measure_general', ['tenant_id'], unique=False, schema='a76') + op.create_table('us_tariff_fractions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('code', sa.String(length=16), nullable=False, comment='Código de fracción americana'), + sa.Column('prefix', sa.String(length=10), nullable=True, comment='Prefijo de clasificación'), + sa.Column('type_code', sa.String(length=10), nullable=True, comment='Código de tipo'), + sa.Column('ad_valorem', sa.Numeric(precision=10, scale=2), nullable=True, comment='Porcentaje ad valorem'), + sa.Column('fixed_cost', sa.Numeric(precision=15, scale=8), nullable=True, comment='Tasa fija'), + sa.Column('unit_of_measure', sa.String(length=10), nullable=True, comment='Unidad de medida'), + sa.Column('description', sa.String(), nullable=True, comment='Descripción de la fracción'), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + schema='a76' + ) + op.create_index(op.f('ix_a76_us_tariff_fractions_company_id'), 'us_tariff_fractions', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_us_tariff_fractions_id'), 'us_tariff_fractions', ['id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_us_tariff_fractions_tenant_id'), 'us_tariff_fractions', ['tenant_id'], unique=False, schema='a76') + op.create_table('value_manifestations', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('manifestation_number', sa.String(length=100), nullable=True), + sa.Column('pedimento', sa.String(length=15), nullable=True), + sa.Column('periodicity', sa.String(length=10), nullable=True), + sa.Column('semester', sa.SmallInteger(), nullable=True), + sa.Column('year', sa.String(length=4), nullable=True), + sa.Column('pedimento_type', sa.String(length=3), nullable=True), + sa.Column('aa_code', sa.String(length=5), nullable=True), + sa.Column('patent', sa.String(length=4), nullable=True), + sa.Column('first_name', sa.String(length=80), nullable=True), + sa.Column('last_name_paternal', sa.String(length=80), nullable=True), + sa.Column('last_name_maternal', sa.String(length=80), nullable=True), + sa.Column('methods_count', sa.Integer(), nullable=True), + sa.Column('merchandise_value_method', sa.String(length=10), nullable=True), + sa.Column('transaction_value', sa.SmallInteger(), nullable=True), + sa.Column('identical_merchandise_value', sa.SmallInteger(), nullable=True), + sa.Column('similar_merchandise_value', sa.SmallInteger(), nullable=True), + sa.Column('unit_sale_price_value', sa.SmallInteger(), nullable=True), + sa.Column('reconstructed_value', sa.SmallInteger(), nullable=True), + sa.Column('article_78_value', sa.SmallInteger(), nullable=True), + sa.Column('provisional_value_declaration', sa.Integer(), nullable=True), + sa.Column('has_attachments', sa.SmallInteger(), nullable=True), + sa.Column('attachment_pages_number', sa.String(length=100), nullable=True), + sa.Column('transaction_value_paid_price', sa.Numeric(precision=19, scale=8), nullable=True), + sa.Column('price_pre_invoice', sa.SmallInteger(), nullable=True), + sa.Column('price_other_docs', sa.SmallInteger(), nullable=True), + sa.Column('concept_article_66', sa.SmallInteger(), nullable=True), + sa.Column('concept_article_66_breakdown', sa.SmallInteger(), nullable=True), + sa.Column('attachment_article_66', sa.String(length=2), nullable=True), + sa.Column('prepaid_merchandise_article_65', sa.String(length=2), nullable=True), + sa.Column('attachment_article_65', sa.String(length=2), nullable=True), + sa.Column('tax_base_no_sale', sa.String(length=2), nullable=True), + sa.Column('exists_circumstances_article_67_71', sa.String(length=2), nullable=True), + sa.Column('customs_value_attachment', sa.String(length=2), nullable=True), + sa.Column('provisional_value_determination', sa.String(length=2), nullable=True), + sa.Column('merchandise_value_proof_attachment', sa.String(length=2), nullable=True), + sa.Column('legal_rep_rfc', sa.String(length=30), nullable=True), + sa.Column('legal_representative', sa.String(length=100), nullable=True), + sa.Column('date', sa.Integer(), nullable=True), + sa.Column('selected_invoice', sa.String(length=20), nullable=True), + sa.Column('invoice_option', sa.String(length=3), nullable=True), + sa.Column('importer_to_use', sa.String(length=8), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + schema='a76' + ) + op.create_index('idx_value_manifestations_manifestation_number', 'value_manifestations', ['manifestation_number'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_value_manifestations_company_id'), 'value_manifestations', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_value_manifestations_tenant_id'), 'value_manifestations', ['tenant_id'], unique=False, schema='a76') + op.create_table('vehicle', + sa.Column('vehicle_key', sa.String(length=14), nullable=False), + sa.Column('vehicle_id', sa.BigInteger(), nullable=False), + sa.Column('ace_vehicle_key', sa.String(length=10), nullable=True), + sa.Column('transporter_key', sa.String(length=23), nullable=True), + sa.Column('transport_identifier', sa.String(length=30), nullable=True), + sa.Column('transport_type', sa.String(length=2), nullable=True), + sa.Column('entity_code', sa.String(length=1), nullable=True), + sa.Column('transponder_number', sa.String(length=16), nullable=True), + sa.Column('dot_number', sa.String(length=8), nullable=True), + sa.Column('plate_number', sa.String(length=17), nullable=True), + sa.Column('city', sa.String(length=30), nullable=True), + sa.Column('state', sa.String(length=30), nullable=True), + sa.Column('country', sa.String(length=3), nullable=True), + sa.Column('seal', sa.String(length=49), nullable=True), + sa.Column('insurance_company_name', sa.String(length=30), nullable=True), + sa.Column('insurance_number', sa.String(length=20), nullable=True), + sa.Column('insurance_amount', sa.DECIMAL(precision=13, scale=2), nullable=True), + sa.Column('insurance_date', sa.Integer(), nullable=True), + sa.Column('box_number', sa.String(length=300), nullable=True), + sa.Column('brand', sa.String(length=20), nullable=True), + sa.Column('year', sa.String(length=4), nullable=True), + sa.Column('series', sa.String(length=30), nullable=True), + sa.Column('description', sa.String(length=100), nullable=True), + sa.Column('engine_number', sa.String(length=50), nullable=True), + sa.Column('sct_permission', sa.String(length=40), nullable=True), + sa.Column('color', sa.String(length=20), nullable=True), + sa.Column('container_key', sa.String(length=3), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('vehicle_key'), + schema='a76' + ) + op.create_index(op.f('ix_a76_vehicle_company_id'), 'vehicle', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_vehicle_tenant_id'), 'vehicle', ['tenant_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_vehicle_vehicle_id'), 'vehicle', ['vehicle_id'], unique=True, schema='a76') + op.create_table('company_roles', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('code', sa.String(length=100), nullable=False), + sa.Column('description', sa.String(length=255), nullable=True), + sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('company_id', 'tenant_id', 'code', name='uq_company_role_code'), + schema='core' + ) + op.create_index('ix_company_roles_company_id_is_active', 'company_roles', ['company_id', 'tenant_id', 'is_active'], unique=False, schema='core') + op.create_index(op.f('ix_core_company_roles_company_id'), 'company_roles', ['company_id'], unique=False, schema='core') + op.create_index(op.f('ix_core_company_roles_id'), 'company_roles', ['id'], unique=False, schema='core') + op.create_index(op.f('ix_core_company_roles_tenant_id'), 'company_roles', ['tenant_id'], unique=False, schema='core') + op.create_table('task_runs', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('task_id', sa.String(length=255), nullable=False), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=True), + sa.Column('requested_by_user', sa.String(length=255), nullable=True), + sa.Column('task_name', sa.String(length=255), nullable=False), + sa.Column('task_group', sa.String(length=100), nullable=False), + sa.Column('task_origin', sa.String(length=255), nullable=True), + sa.Column('status', sa.String(length=20), nullable=False), + sa.Column('celery_state_raw', sa.String(length=30), nullable=False), + sa.Column('progress_current', sa.Integer(), nullable=True), + sa.Column('progress_total', sa.Integer(), nullable=True), + sa.Column('progress_percent', sa.Float(), nullable=True), + sa.Column('progress_message', sa.String(length=500), nullable=True), + sa.Column('retries', sa.Integer(), nullable=True), + sa.Column('exception_type', sa.String(length=255), nullable=True), + sa.Column('exception_message', sa.Text(), nullable=True), + sa.Column('traceback_excerpt', sa.Text(), nullable=True), + sa.Column('result_summary', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('meta_payload', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('started_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + schema='core' + ) + op.create_index(op.f('ix_core_task_runs_company_id'), 'task_runs', ['company_id'], unique=False, schema='core') + op.create_index('ix_core_task_runs_task_id', 'task_runs', ['task_id'], unique=True, schema='core') + op.create_index('ix_core_task_runs_tenant_company_updated', 'task_runs', ['tenant_id', 'company_id', 'updated_at'], unique=False, schema='core') + op.create_index('ix_core_task_runs_tenant_group_updated', 'task_runs', ['tenant_id', 'task_group', 'updated_at'], unique=False, schema='core') + op.create_index(op.f('ix_core_task_runs_tenant_id'), 'task_runs', ['tenant_id'], unique=False, schema='core') + op.create_index('ix_core_task_runs_tenant_status_updated', 'task_runs', ['tenant_id', 'status', 'updated_at'], unique=False, schema='core') + op.create_index('ix_core_task_runs_tenant_updated', 'task_runs', ['tenant_id', 'updated_at'], unique=False, schema='core') + op.create_table('user_company_permissions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.String(length=100), nullable=False), + sa.Column('permission_id', sa.Integer(), nullable=False), + sa.Column('is_granted', sa.Boolean(), server_default='true', nullable=False), + sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False), + sa.Column('assigned_by', sa.String(length=100), nullable=True), + sa.Column('expires_at', sa.DateTime(), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['permission_id'], ['core.permissions.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id', 'company_id', 'tenant_id', 'permission_id', name='uq_user_company_permission'), + schema='core' + ) + op.create_index(op.f('ix_core_user_company_permissions_company_id'), 'user_company_permissions', ['company_id'], unique=False, schema='core') + op.create_index(op.f('ix_core_user_company_permissions_id'), 'user_company_permissions', ['id'], unique=False, schema='core') + op.create_index(op.f('ix_core_user_company_permissions_permission_id'), 'user_company_permissions', ['permission_id'], unique=False, schema='core') + op.create_index(op.f('ix_core_user_company_permissions_tenant_id'), 'user_company_permissions', ['tenant_id'], unique=False, schema='core') + op.create_index(op.f('ix_core_user_company_permissions_user_id'), 'user_company_permissions', ['user_id'], unique=False, schema='core') + op.create_index('ix_user_company_permissions_composite', 'user_company_permissions', ['user_id', 'company_id', 'tenant_id', 'is_active'], unique=False, schema='core') + op.create_table('user_tenants', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('keycloak_user_id', sa.String(length=255), nullable=False), + sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False), + sa.Column('role', sa.String(length=50), nullable=True), + sa.Column('avatar_url', sa.String(length=500), nullable=True, comment='URL de la imagen de perfil'), + sa.Column('phone', sa.String(length=20), nullable=True, comment='Teléfono del usuario'), + sa.Column('bio', sa.Text(), nullable=True, comment='Biografía del usuario'), + sa.Column('preferences', sa.JSON(), nullable=True, comment='Preferencias del usuario (tema, idioma, etc.)'), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('keycloak_user_id', 'tenant_id', 'company_id', name='uq_user_tenant'), + schema='core' + ) + op.create_index(op.f('ix_core_user_tenants_company_id'), 'user_tenants', ['company_id'], unique=False, schema='core') + op.create_index(op.f('ix_core_user_tenants_id'), 'user_tenants', ['id'], unique=False, schema='core') + op.create_index(op.f('ix_core_user_tenants_keycloak_user_id'), 'user_tenants', ['keycloak_user_id'], unique=False, schema='core') + op.create_index(op.f('ix_core_user_tenants_tenant_id'), 'user_tenants', ['tenant_id'], unique=False, schema='core') + op.create_table('warning_fractions', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('fraction', sa.String(length=15), nullable=False), + sa.Column('description', sa.String(length=1000), nullable=True), + sa.Column('warning_type', sa.String(length=50), nullable=True), + sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='warning_fractions_pkey'), + sa.UniqueConstraint('fraction', 'company_id', name='uq_warning_fractions_fraction_company'), + schema='public' + ) + op.create_index(op.f('ix_public_warning_fractions_company_id'), 'warning_fractions', ['company_id'], unique=False, schema='public') + op.create_index(op.f('ix_public_warning_fractions_fraction'), 'warning_fractions', ['fraction'], unique=False, schema='public') + op.create_index(op.f('ix_public_warning_fractions_tenant_id'), 'warning_fractions', ['tenant_id'], unique=False, schema='public') + op.create_index(op.f('ix_public_warning_fractions_warning_type'), 'warning_fractions', ['warning_type'], unique=False, schema='public') + op.create_table('discharge_header', + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('source_invoice_id', sa.BigInteger(), nullable=False, comment='Export, SM-out or CTM-send invoice that owns this discharge.'), + sa.Column('def_import_invoice_id', sa.BigInteger(), nullable=True, comment='Populated only for discharge_type=DEFINITIVE.'), + sa.Column('discharge_type', sa.String(length=15), nullable=False), + sa.Column('status', sa.String(length=15), server_default=sa.text("'applied'"), nullable=False), + sa.Column('discharge_date', sa.Date(), nullable=False), + sa.Column('reference_invoice', sa.String(length=19), nullable=True, comment='FACREFERENCIA — for rectifications'), + sa.Column('discharge_subtype', sa.String(length=10), nullable=True, comment='TIPODESC: NORMAL, PARCIAL, REPARACION, UTILERIA'), + sa.Column('partial_sequence', sa.Integer(), nullable=True, comment='CONSECPARCIAL — for partial discharges'), + sa.Column('sales_order', sa.String(length=20), nullable=True), + sa.Column('ctm_section', sa.String(length=3), nullable=True), + sa.Column('is_tooling', sa.Boolean(), server_default=sa.text('false'), nullable=False, comment='PORUTILERIA'), + sa.Column('discharge_sm', sa.String(length=4), nullable=True), + sa.Column('is_repair_update', sa.Boolean(), server_default=sa.text('false'), nullable=False, comment='ACTUALREPARACION'), + sa.Column('material_type_expo', sa.String(length=10), nullable=True, comment='TIPOMATEXPO'), + sa.Column('cancelled_by', sa.String(length=100), nullable=True), + sa.Column('cancellation_reason', sa.String(length=300), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['def_import_invoice_id'], ['a76.invoice_header.id'], ), + sa.ForeignKeyConstraint(['source_invoice_id'], ['a76.invoice_header.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + schema='a24' + ) + op.create_index(op.f('ix_a24_discharge_header_company_id'), 'discharge_header', ['company_id'], unique=False, schema='a24') + op.create_index(op.f('ix_a24_discharge_header_tenant_id'), 'discharge_header', ['tenant_id'], unique=False, schema='a24') + op.create_index('ix_dischdr_date', 'discharge_header', ['tenant_id', 'discharge_date', 'discharge_type'], unique=False, schema='a24') + op.create_index('ix_dischdr_source', 'discharge_header', ['source_invoice_id', 'status'], unique=False, schema='a24') + op.create_table('classes', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('class_code', sa.String(length=8), nullable=False), + sa.Column('description_es', sa.String(length=500), nullable=True), + sa.Column('description_en', sa.String(length=500), nullable=True), + sa.Column('material_key', sa.String(length=10), nullable=True), + sa.Column('unit_of_measure', sa.String(length=5), nullable=True), + sa.Column('fraction', sa.String(length=20), nullable=True), + sa.Column('us_fraction', sa.String(length=16), nullable=True), + sa.Column('sub_key', sa.String(length=5), nullable=True), + sa.Column('physical_review', sa.SmallInteger(), nullable=True), + sa.Column('iva_exempt_fraction', sa.String(length=4), nullable=True), + sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['material_key'], ['public.material_types.key'], name='fk_classes_material_type'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.ForeignKeyConstraint(['unit_of_measure', 'tenant_id', 'company_id'], ['a76.units_of_measure.code', 'a76.units_of_measure.tenant_id', 'a76.units_of_measure.company_id'], ), + sa.PrimaryKeyConstraint('id', name='classes_pkey'), + sa.UniqueConstraint('tenant_id', 'company_id', 'class_code', name='uq_classes_tenant_company_code'), + schema='a76' + ) + op.create_index(op.f('ix_a76_classes_company_id'), 'classes', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_classes_tenant_id'), 'classes', ['tenant_id'], unique=False, schema='a76') + op.create_table('clients_and_providers_address', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('client_id', sa.Integer(), nullable=False), + sa.Column('municipality', sa.String(length=150), nullable=True), + sa.Column('streets', sa.String(length=100), nullable=True), + sa.Column('neighborhood', sa.String(length=40), nullable=True), + sa.Column('interior_number', sa.String(length=20), nullable=True), + sa.Column('exterior_number', sa.String(length=20), nullable=True), + sa.Column('postal_code', sa.String(length=15), nullable=True), + sa.Column('city', sa.String(length=30), nullable=True), + sa.Column('state', sa.String(length=30), nullable=True), + sa.Column('country', sa.String(length=3), nullable=True), + sa.Column('phone', sa.String(length=30), nullable=True), + sa.Column('fax_number', sa.String(length=30), nullable=True), + sa.Column('email', sa.String(length=100), nullable=True), + sa.Column('contact', sa.String(length=50), nullable=True), + sa.Column('reference', sa.String(length=250), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['client_id'], ['a76.clients_and_providers.id'], name='fk_clients_and_providers_address_client', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['client_id'], ['a76.clients_and_providers.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='clients_and_providers_address_pkey'), + schema='a76' + ) + op.create_index(op.f('ix_a76_clients_and_providers_address_company_id'), 'clients_and_providers_address', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_clients_and_providers_address_tenant_id'), 'clients_and_providers_address', ['tenant_id'], unique=False, schema='a76') + op.create_table('clients_and_providers_programs', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('client_id', sa.Integer(), nullable=False), + sa.Column('program', sa.String(length=7), nullable=True), + sa.Column('program_number', sa.String(length=40), nullable=True), + sa.Column('prosec', sa.String(length=8), nullable=True), + sa.Column('prosec_authorization', sa.String(length=20), nullable=True), + sa.Column('secon_auth_date', sa.Integer(), nullable=True), + sa.Column('manufacturer_id', sa.String(length=25), nullable=True), + sa.Column('broker', sa.String(length=6), nullable=True), + sa.Column('import_broker', sa.String(length=6), nullable=True), + sa.Column('transfer_key', sa.String(length=8), nullable=True), + sa.Column('secon_authorization', sa.String(length=20), nullable=True), + sa.Column('applied_proportion', sa.Numeric(precision=7, scale=2), nullable=True), + sa.Column('is_certified_company', sa.String(length=1), nullable=True), + sa.Column('certified_company_registry', sa.String(length=40), nullable=True), + sa.Column('donation_auth_number', sa.String(length=50), nullable=True), + sa.Column('ctpat_svi', sa.String(length=100), nullable=True), + sa.Column('tax_registry_number', sa.String(length=40), nullable=True), + sa.Column('subassembly_service', sa.SmallInteger(), nullable=True), + sa.Column('autse_dates', sa.Integer(), nullable=True), + sa.Column('autse_number', sa.String(length=300), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['client_id'], ['a76.clients_and_providers.id'], name='fk_clients_and_providers_programs_client', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['client_id'], ['a76.clients_and_providers.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='clients_and_providers_programs_pkey'), + schema='a76' + ) + op.create_index(op.f('ix_a76_clients_and_providers_programs_company_id'), 'clients_and_providers_programs', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_clients_and_providers_programs_tenant_id'), 'clients_and_providers_programs', ['tenant_id'], unique=False, schema='a76') + op.create_table('concept_manifestations', + sa.Column('value_manifestation_id', sa.Integer(), nullable=False), + sa.Column('line_number', sa.Integer(), nullable=False), + sa.Column('attachment_type', sa.String(length=10), nullable=True), + sa.Column('number', sa.String(length=10), nullable=True), + sa.Column('merchandise_provider', sa.String(length=100), nullable=True), + sa.Column('invoice_document', sa.String(length=200), nullable=True), + sa.Column('amount', sa.Numeric(precision=19, scale=9), nullable=True), + sa.Column('currency', sa.String(length=3), nullable=True), + sa.Column('concept_load', sa.String(length=200), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.ForeignKeyConstraint(['value_manifestation_id'], ['a76.value_manifestations.id'], name='fk_concept_manifestation_value_manifestation'), + sa.PrimaryKeyConstraint('value_manifestation_id', 'line_number', name='concept_manifestations_pkey'), + schema='a76' + ) + op.create_index('idx_concept_manifestations_value_manifestation_id', 'concept_manifestations', ['value_manifestation_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_concept_manifestations_company_id'), 'concept_manifestations', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_concept_manifestations_tenant_id'), 'concept_manifestations', ['tenant_id'], unique=False, schema='a76') + op.create_table('concepts', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('code', sa.String(length=15), nullable=False), + sa.Column('description', sa.String(length=120), nullable=True), + sa.Column('description_en', sa.String(length=120), nullable=True), + sa.Column('detailed_description', sa.String(length=1000), nullable=True), + sa.Column('priority', sa.Integer(), nullable=True), + sa.Column('priority_ame', sa.Integer(), nullable=True), + sa.Column('first_total', sa.Boolean(), nullable=True), + sa.Column('type', sa.String(length=9), nullable=True), + sa.Column('is_printed', sa.Boolean(), nullable=True), + sa.Column('section', sa.Integer(), nullable=True), + sa.Column('classification', sa.String(length=30), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['classification'], ['a76.classification_concepts.classification'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('code', name='uq_concept_code'), + schema='a76' + ) + op.create_index(op.f('ix_a76_concepts_tenant_id'), 'concepts', ['tenant_id'], unique=False, schema='a76') + op.create_table('country_rule_oct', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('permission', sa.String(length=20), nullable=False), + sa.Column('line', sa.Integer(), nullable=False), + sa.Column('fraction', sa.String(length=10), nullable=False), + sa.Column('country_code', sa.String(length=3), nullable=False), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id', 'company_id', 'permission', 'line', 'fraction'], ['a76.fraction_rule_octave.tenant_id', 'a76.fraction_rule_octave.company_id', 'a76.fraction_rule_octave.permission', 'a76.fraction_rule_octave.line', 'a76.fraction_rule_octave.fraction'], name='fk_country_rule_oct_frac_octava', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='country_rule_oct_pkey'), + sa.UniqueConstraint('tenant_id', 'company_id', 'permission', 'line', 'fraction', 'country_code', name='uq_country_rule_oct_permission_line_fraction_country'), + schema='a76' + ) + op.create_index(op.f('ix_a76_country_rule_oct_company_id'), 'country_rule_oct', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_country_rule_oct_tenant_id'), 'country_rule_oct', ['tenant_id'], unique=False, schema='a76') + op.create_table('customs_brokers_personnel', + sa.Column('customs_broker_id', sa.Integer(), nullable=False), + sa.Column('line', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=True), + sa.Column('tax_id', sa.String(length=30), nullable=True), + sa.Column('personal_id', sa.String(length=20), nullable=True), + sa.Column('position', sa.String(length=30), nullable=True), + sa.Column('license', sa.String(length=4), nullable=True), + sa.Column('first_name', sa.String(length=80), nullable=True), + sa.Column('last_name', sa.String(length=80), nullable=True), + sa.Column('middle_name', sa.String(length=80), nullable=True), + sa.Column('email', sa.String(length=100), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['customs_broker_id'], ['a76.customs_brokers.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('customs_broker_id', 'line'), + schema='a76' + ) + op.create_index(op.f('ix_a76_customs_brokers_personnel_company_id'), 'customs_brokers_personnel', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_customs_brokers_personnel_tenant_id'), 'customs_brokers_personnel', ['tenant_id'], unique=False, schema='a76') + op.create_table('customs_brokers_vu', + sa.Column('customs_broker_id', sa.Integer(), nullable=False), + sa.Column('certificate_path', sa.String(length=1499), nullable=True), + sa.Column('key_path', sa.String(length=1499), nullable=True), + sa.Column('access_key', sa.String(length=50), nullable=True), + sa.Column('fiel_format', sa.String(length=19), nullable=True), + sa.Column('signature_read_path', sa.String(length=1499), nullable=True), + sa.Column('archive_path', sa.String(length=1499), nullable=True), + sa.Column('fiel_access_key', sa.String(length=50), nullable=True), + sa.Column('web_service_user', sa.String(length=100), nullable=True), + sa.Column('web_service_access_key', sa.String(length=100), nullable=True), + sa.Column('vu_email', sa.String(length=800), nullable=True), + sa.Column('vu_figure_type', sa.String(length=29), nullable=True), + sa.Column('xml_files_path', sa.String(length=1499), nullable=True), + sa.Column('query_tax_id', sa.String(length=30), nullable=True), + sa.Column('doda_certificate_path', sa.String(length=1499), nullable=True), + sa.Column('doda_key_path', sa.String(length=1499), nullable=True), + sa.Column('doda_web_service_user', sa.String(length=100), nullable=True), + sa.Column('doda_web_service_access_key', sa.String(length=100), nullable=True), + sa.Column('doda_fiel_access_key', sa.String(length=50), nullable=True), + sa.Column('doda_xml_files_path', sa.String(length=1499), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['customs_broker_id'], ['a76.customs_brokers.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('customs_broker_id'), + schema='a76' + ) + op.create_index(op.f('ix_a76_customs_brokers_vu_company_id'), 'customs_brokers_vu', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_customs_brokers_vu_tenant_id'), 'customs_brokers_vu', ['tenant_id'], unique=False, schema='a76') + op.create_table('doda_american_pedimentos', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('doda_id', sa.Integer(), nullable=False), + sa.Column('american_pedimento_line', sa.Integer(), nullable=False), + sa.Column('american_pedimento_type', sa.String(length=2), nullable=True), + sa.Column('american_pedimento_value', sa.String(length=20), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['doda_id'], ['a76.doda.id'], name='fk_doda_american_pedimentos_doda'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='doda_american_pedimentos_pkey'), + schema='a76' + ) + op.create_index(op.f('ix_a76_doda_american_pedimentos_company_id'), 'doda_american_pedimentos', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_doda_american_pedimentos_tenant_id'), 'doda_american_pedimentos', ['tenant_id'], unique=False, schema='a76') + op.create_table('doda_containers', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('doda_id', sa.Integer(), nullable=False), + sa.Column('container_line', sa.Integer(), nullable=False), + sa.Column('container_value', sa.String(length=20), nullable=True), + sa.Column('seals', sa.String(length=254), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['doda_id'], ['a76.doda.id'], name='fk_doda_containers_doda'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='doda_containers_pkey'), + schema='a76' + ) + op.create_index(op.f('ix_a76_doda_containers_company_id'), 'doda_containers', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_doda_containers_tenant_id'), 'doda_containers', ['tenant_id'], unique=False, schema='a76') + op.create_table('doda_pedimentos', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('doda_id', sa.Integer(), nullable=False), + sa.Column('pedimento_line', sa.Integer(), nullable=False), + sa.Column('authorization_patent', sa.String(length=10), nullable=True), + sa.Column('document', sa.String(length=50), nullable=True), + sa.Column('shipment', sa.String(length=11), nullable=True), + sa.Column('cove', sa.String(length=50), nullable=True), + sa.Column('umc', sa.String(length=20), nullable=True), + sa.Column('effective_amount_usd', sa.Numeric(precision=15, scale=2), nullable=True), + sa.Column('difference_amount_usd', sa.Numeric(precision=15, scale=2), nullable=True), + sa.Column('dta_niu', sa.String(length=20), nullable=True), + sa.Column('article_7', sa.Boolean(), nullable=True), + sa.Column('pedimento_id', sa.Integer(), nullable=True), + sa.Column('invoice_line', sa.Integer(), nullable=True), + sa.Column('part_ii_line', sa.Integer(), nullable=True), + sa.Column('pedimento_type', sa.String(length=20), nullable=True), + sa.Column('zero_packaging_validation', sa.Boolean(), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['doda_id'], ['a76.doda.id'], name='fk_doda_pedimentos_doda'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='doda_pedimentos_pkey'), + schema='a76' + ) + op.create_index(op.f('ix_a76_doda_pedimentos_company_id'), 'doda_pedimentos', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_doda_pedimentos_tenant_id'), 'doda_pedimentos', ['tenant_id'], unique=False, schema='a76') + op.create_table('driver', + sa.Column('transporter_key', sa.String(length=30), nullable=False), + sa.Column('line', sa.Integer(), nullable=False), + sa.Column('driver_id', sa.BigInteger(), nullable=False), + sa.Column('driver_name', sa.String(length=80), nullable=True), + sa.Column('license_number', sa.String(length=29), nullable=True), + sa.Column('express_line_id', sa.String(length=17), nullable=True), + sa.Column('ace_id', sa.String(length=20), nullable=True), + sa.Column('birth_date', sa.Integer(), nullable=True), + sa.Column('gender', sa.String(length=1), nullable=True), + sa.Column('birth_country', sa.String(length=3), nullable=True), + sa.Column('hazardous_material_auth', sa.String(length=2), nullable=True), + sa.Column('hazardous_material_state', sa.String(length=30), nullable=True), + sa.Column('first_name', sa.String(length=20), nullable=True), + sa.Column('last_name', sa.String(length=20), nullable=True), + sa.Column('id_key1', sa.String(length=40), nullable=True), + sa.Column('id_number1', sa.String(length=20), nullable=True), + sa.Column('id_state1', sa.String(length=30), nullable=True), + sa.Column('id_country1', sa.String(length=3), nullable=True), + sa.Column('id_key2', sa.String(length=40), nullable=True), + sa.Column('id_number2', sa.String(length=20), nullable=True), + sa.Column('id_state2', sa.String(length=30), nullable=True), + sa.Column('id_country2', sa.String(length=3), nullable=True), + sa.Column('badge_number', sa.String(length=20), nullable=True), + sa.Column('class_type', sa.String(length=1), nullable=True), + sa.Column('unique_badge_number', sa.String(length=100), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.ForeignKeyConstraint(['transporter_key'], ['a76.transporter.transporter_key'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('transporter_key', 'line'), + schema='a76' + ) + op.create_index(op.f('ix_a76_driver_company_id'), 'driver', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_driver_driver_id'), 'driver', ['driver_id'], unique=True, schema='a76') + op.create_index(op.f('ix_a76_driver_tenant_id'), 'driver', ['tenant_id'], unique=False, schema='a76') + op.create_table('equivalencies', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('identifier', sa.String(length=10), nullable=False), + sa.Column('description', sa.String(length=200), nullable=True), + sa.Column('item_id', sa.Integer(), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['item_id'], ['a76.equivalency_items.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('identifier', 'tenant_id', 'company_id', name='uq_equivalency_identifier'), + schema='a76' + ) + op.create_index(op.f('ix_a76_equivalencies_company_id'), 'equivalencies', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_equivalencies_tenant_id'), 'equivalencies', ['tenant_id'], unique=False, schema='a76') + op.create_table('error_catalogs', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('code', sa.String(length=15), nullable=False), + sa.Column('description', sa.String(length=255), nullable=True), + sa.Column('classification_id', sa.Integer(), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['classification_id'], ['a76.error_classifications.id'], name='fk_error_catalogs_classification'), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='error_catalogs_pkey'), + sa.UniqueConstraint('code'), + sa.UniqueConstraint('code', name='error_catalogs_code_unique'), + schema='a76' + ) + op.create_index(op.f('ix_a76_error_catalogs_company_id'), 'error_catalogs', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_error_catalogs_tenant_id'), 'error_catalogs', ['tenant_id'], unique=False, schema='a76') + op.create_table('fa_location_ext', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('location_id', sa.Integer(), nullable=False), + sa.Column('department', sa.String(length=100), nullable=True), + sa.Column('responsible', sa.String(length=200), nullable=True), + sa.Column('observations', sa.Text(), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['location_id'], ['a76.location.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('location_id'), + sa.UniqueConstraint('location_id', name='uq_fa_location_ext_location_id'), + schema='a76' + ) + op.create_index(op.f('ix_a76_fa_location_ext_company_id'), 'fa_location_ext', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_fa_location_ext_tenant_id'), 'fa_location_ext', ['tenant_id'], unique=False, schema='a76') + op.create_table('fda_affirmation_codes', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('fda_catalog_id', sa.Integer(), nullable=False), + sa.Column('line', sa.Integer(), nullable=False), + sa.Column('aoc_code', sa.String(length=50), nullable=False), + sa.Column('aoc_qual', sa.String(length=100), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['fda_catalog_id'], ['a76.fda_catalog.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='fda_affirmation_codes_pkey'), + schema='a76' + ) + op.create_index(op.f('ix_a76_fda_affirmation_codes_company_id'), 'fda_affirmation_codes', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_fda_affirmation_codes_fda_catalog_id'), 'fda_affirmation_codes', ['fda_catalog_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_fda_affirmation_codes_tenant_id'), 'fda_affirmation_codes', ['tenant_id'], unique=False, schema='a76') + op.create_table('fda_constituent_elements', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('fda_catalog_id', sa.Integer(), nullable=False), + sa.Column('line', sa.Integer(), nullable=False), + sa.Column('ele_name', sa.String(length=200), nullable=False), + sa.Column('ele_qty', sa.Numeric(precision=15, scale=2), nullable=True), + sa.Column('ele_qty_uom', sa.String(length=20), nullable=True), + sa.Column('ele_pctg', sa.Numeric(precision=15, scale=2), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['fda_catalog_id'], ['a76.fda_catalog.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='fda_constituent_elements_pkey'), + schema='a76' + ) + op.create_index(op.f('ix_a76_fda_constituent_elements_company_id'), 'fda_constituent_elements', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_fda_constituent_elements_fda_catalog_id'), 'fda_constituent_elements', ['fda_catalog_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_fda_constituent_elements_tenant_id'), 'fda_constituent_elements', ['tenant_id'], unique=False, schema='a76') + op.create_table('fda_lot_production', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('fda_catalog_id', sa.Integer(), nullable=False), + sa.Column('line', sa.Integer(), nullable=False), + sa.Column('lot_number', sa.String(length=100), nullable=False), + sa.Column('production_start_date', sa.String(length=50), nullable=True), + sa.Column('production_end_date', sa.String(length=50), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['fda_catalog_id'], ['a76.fda_catalog.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='fda_lot_production_pkey'), + schema='a76' + ) + op.create_index(op.f('ix_a76_fda_lot_production_company_id'), 'fda_lot_production', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_fda_lot_production_fda_catalog_id'), 'fda_lot_production', ['fda_catalog_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_fda_lot_production_tenant_id'), 'fda_lot_production', ['tenant_id'], unique=False, schema='a76') + op.create_table('fda_specifications', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('fda_catalog_id', sa.Integer(), nullable=False), + sa.Column('prod_code', sa.String(length=50), nullable=True), + sa.Column('commodity_desc', sa.String(length=200), nullable=True), + sa.Column('brand_name', sa.String(length=100), nullable=True), + sa.Column('disclaimer', sa.String(length=100), nullable=True), + sa.Column('pgm_code', sa.String(length=50), nullable=True), + sa.Column('proc_code', sa.String(length=50), nullable=True), + sa.Column('intnd_use_code', sa.String(length=50), nullable=True), + sa.Column('intnd_use_desc', sa.String(length=200), nullable=True), + sa.Column('temp_qual', sa.String(length=50), nullable=True), + sa.Column('temp_type', sa.String(length=50), nullable=True), + sa.Column('temp_degrees', sa.Numeric(precision=10, scale=2), nullable=True), + sa.Column('temp_negative', sa.Numeric(precision=10, scale=2), nullable=True), + sa.Column('temp_location', sa.String(length=100), nullable=True), + sa.Column('quantity_1', sa.Numeric(precision=15, scale=2), nullable=True), + sa.Column('qty_uom_1', sa.String(length=20), nullable=True), + sa.Column('quantity_2', sa.Numeric(precision=15, scale=2), nullable=True), + sa.Column('qty_uom_2', sa.String(length=20), nullable=True), + sa.Column('quantity_3', sa.Numeric(precision=15, scale=2), nullable=True), + sa.Column('qty_uom_3', sa.String(length=20), nullable=True), + sa.Column('ctry_prod', sa.String(length=50), nullable=True), + sa.Column('ctry_source', sa.String(length=50), nullable=True), + sa.Column('ctry_growth', sa.String(length=50), nullable=True), + sa.Column('ctry_refusal', sa.String(length=50), nullable=True), + sa.Column('ctry_shipping', sa.String(length=50), nullable=True), + sa.Column('manuf_key', sa.String(length=50), nullable=True), + sa.Column('shipper_key', sa.String(length=50), nullable=True), + sa.Column('ult_cons_key', sa.String(length=50), nullable=True), + sa.Column('fda_imp_key', sa.String(length=50), nullable=True), + sa.Column('pn_subm_key', sa.String(length=50), nullable=True), + sa.Column('consol_key', sa.String(length=50), nullable=True), + sa.Column('producer_key', sa.String(length=50), nullable=True), + sa.Column('owner_key', sa.String(length=50), nullable=True), + sa.Column('deli_party_key', sa.String(length=50), nullable=True), + sa.Column('grower_key', sa.String(length=50), nullable=True), + sa.Column('dev_ini_imp_key', sa.String(length=50), nullable=True), + sa.Column('lacf_cont_1', sa.String(length=100), nullable=True), + sa.Column('lacf_cont_2', sa.String(length=100), nullable=True), + sa.Column('lacf_cont_3', sa.String(length=100), nullable=True), + sa.Column('pn_transmitter_key', sa.String(length=50), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['fda_catalog_id'], ['a76.fda_catalog.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='fda_specifications_pkey'), + schema='a76' + ) + op.create_index(op.f('ix_a76_fda_specifications_company_id'), 'fda_specifications', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_fda_specifications_fda_catalog_id'), 'fda_specifications', ['fda_catalog_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_fda_specifications_tenant_id'), 'fda_specifications', ['tenant_id'], unique=False, schema='a76') + op.create_table('invoice_collections', + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('invoice_id', sa.BigInteger(), nullable=False), + sa.Column('line_number', sa.Integer(), nullable=False), + sa.Column('invoice_number', sa.String(length=15), nullable=True), + sa.Column('concept', sa.String(length=100), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['invoice_id'], ['a76.invoice_header.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + schema='a76' + ) + op.create_index(op.f('ix_a76_invoice_collections_company_id'), 'invoice_collections', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_invoice_collections_tenant_id'), 'invoice_collections', ['tenant_id'], unique=False, schema='a76') + op.create_table('invoice_financials', + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('invoice_id', sa.BigInteger(), nullable=False), + sa.Column('currency', sa.String(length=7), nullable=False), + sa.Column('currency_type', sa.String(length=3), nullable=True), + sa.Column('exchange_rate', sa.Numeric(precision=13, scale=6), nullable=True), + sa.Column('exchange_rate_mm', sa.Numeric(precision=13, scale=6), nullable=True), + sa.Column('value_mn', sa.Numeric(precision=23, scale=8), server_default='0', nullable=True), + sa.Column('value_me', sa.Numeric(precision=23, scale=8), server_default='0', nullable=True), + sa.Column('value_mc', sa.Numeric(precision=23, scale=8), server_default='0', nullable=True), + sa.Column('customs_value_mn', sa.Numeric(precision=23, scale=8), server_default='0', nullable=True), + sa.Column('customs_value_me', sa.Numeric(precision=23, scale=8), server_default='0', nullable=True), + sa.Column('raw_material_value_mn', sa.Numeric(precision=23, scale=8), server_default='0', nullable=True), + sa.Column('raw_material_value_me', sa.Numeric(precision=23, scale=8), server_default='0', nullable=True), + sa.Column('aggregate_value_mn', sa.Numeric(precision=23, scale=8), server_default='0', nullable=True), + sa.Column('aggregate_value_me', sa.Numeric(precision=23, scale=8), server_default='0', nullable=True), + sa.Column('aggregate_value_mc', sa.Numeric(precision=23, scale=8), server_default='0', nullable=True), + sa.Column('mexican_value_mn', sa.Numeric(precision=23, scale=8), server_default='0', nullable=True), + sa.Column('mexican_value_me', sa.Numeric(precision=23, scale=8), server_default='0', nullable=True), + sa.Column('mexican_value_mc', sa.Numeric(precision=23, scale=8), server_default='0', nullable=True), + sa.Column('national_packaging_mn', sa.Numeric(precision=23, scale=8), server_default='0', nullable=True), + sa.Column('national_packaging_me', sa.Numeric(precision=23, scale=8), server_default='0', nullable=True), + sa.Column('national_packaging_mc', sa.Numeric(precision=23, scale=8), server_default='0', nullable=True), + sa.Column('freight', sa.Numeric(precision=19, scale=8), server_default='0', nullable=True), + sa.Column('insurance', sa.Numeric(precision=19, scale=8), server_default='0', nullable=True), + sa.Column('insurance_value', sa.Numeric(precision=19, scale=8), server_default='0', nullable=True), + sa.Column('packaging', sa.Numeric(precision=19, scale=8), server_default='0', nullable=True), + sa.Column('other_increments', sa.Numeric(precision=19, scale=8), server_default='0', nullable=True), + sa.Column('other_deductibles', sa.Numeric(precision=19, scale=8), server_default='0', nullable=True), + sa.Column('total_increments_mn', sa.Numeric(precision=23, scale=8), server_default='0', nullable=True), + sa.Column('total_increments_me', sa.Numeric(precision=23, scale=8), server_default='0', nullable=True), + sa.Column('iva_mn', sa.Numeric(precision=23, scale=8), server_default='0', nullable=True), + sa.Column('iva_me', sa.Numeric(precision=23, scale=8), server_default='0', nullable=True), + sa.Column('iva_mc', sa.Numeric(precision=23, scale=8), server_default='0', nullable=True), + sa.Column('iva_factor', sa.Numeric(precision=23, scale=8), server_default='0', nullable=True), + sa.Column('tax_value_me', sa.Numeric(precision=23, scale=8), server_default='0', nullable=True), + sa.Column('seal_value_2500', sa.Boolean(), nullable=True), + sa.Column('total_quantity', sa.Numeric(precision=19, scale=8), nullable=True), + sa.Column('total_packages', sa.Integer(), nullable=True), + sa.Column('gross_weight', sa.Numeric(precision=19, scale=8), nullable=True), + sa.Column('net_weight', sa.Numeric(precision=19, scale=8), nullable=True), + sa.Column('bundle_count', sa.Integer(), nullable=True), + sa.Column('weight_factor', sa.Numeric(precision=19, scale=8), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['currency_type'], ['public.currency_types.code'], ), + sa.ForeignKeyConstraint(['invoice_id'], ['a76.invoice_header.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + schema='a76' + ) + op.create_index(op.f('ix_a76_invoice_financials_company_id'), 'invoice_financials', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_invoice_financials_tenant_id'), 'invoice_financials', ['tenant_id'], unique=False, schema='a76') + op.create_table('invoice_logistics', + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('invoice_id', sa.BigInteger(), nullable=False), + sa.Column('carrier_id', sa.String(length=10), nullable=True), + sa.Column('carrier_int_id', sa.BigInteger(), nullable=True), + sa.Column('transport_id', sa.String(length=10), nullable=True), + sa.Column('transport_int_id', sa.BigInteger(), nullable=True), + sa.Column('transport_us_id', sa.String(length=10), nullable=True), + sa.Column('transport_type', sa.String(length=15), server_default='none', nullable=False), + sa.Column('transport_num', sa.String(length=20), nullable=True), + sa.Column('transport_mode', sa.String(length=15), nullable=True), + sa.Column('driver_name', sa.String(length=80), nullable=True), + sa.Column('is_rail', sa.Boolean(), server_default='false', nullable=True), + sa.Column('rail_id', sa.String(length=31), nullable=True), + sa.Column('vehicle_num', sa.String(length=20), nullable=True), + sa.Column('license_plate', sa.String(length=20), nullable=True), + sa.Column('license_plate_complete', sa.String(length=40), nullable=True), + sa.Column('trailer_num', sa.String(length=20), nullable=True), + sa.Column('trailer_int_id', sa.BigInteger(), nullable=True), + sa.Column('seal_number', sa.String(length=15), nullable=True), + sa.Column('guide_number', sa.String(length=20), nullable=True), + sa.Column('bill_number', sa.String(length=15), nullable=True), + sa.Column('reference_number', sa.String(length=14), nullable=True), + sa.Column('shipment_number', sa.String(length=19), nullable=True), + sa.Column('incoterm', sa.String(length=5), nullable=True), + sa.Column('identifier_1', sa.String(length=2), nullable=True), + sa.Column('complement_1', sa.String(length=30), nullable=True), + sa.Column('identifier_2', sa.String(length=2), nullable=True), + sa.Column('complement_2', sa.String(length=30), nullable=True), + sa.Column('weight_type', sa.String(length=3), nullable=False), + sa.Column('container_types', sa.String(length=500), nullable=True), + sa.Column('vehicle_data', sa.String(length=500), nullable=True), + sa.Column('origin_location', sa.String(length=200), nullable=True), + sa.Column('destination_location', sa.String(length=200), nullable=True), + sa.Column('transport_itinerary', sa.String(length=1000), nullable=True), + sa.Column('destination_goods', sa.String(length=50), nullable=True), + sa.Column('entry_exit_date', sa.Date(), nullable=True), + sa.Column('delivery_date', sa.Date(), nullable=True), + sa.Column('delivered_status', sa.Boolean(), server_default='false', nullable=True), + sa.Column('received_by', sa.String(length=50), nullable=True), + sa.Column('payment_date', sa.Date(), nullable=True), + sa.Column('payment_receipt_num', sa.String(length=20), nullable=True), + sa.Column('is_ctm_process', sa.Boolean(), server_default='false', nullable=True), + sa.Column('equipment_reviewed', sa.Boolean(), nullable=True), + sa.Column('is_subdivision', sa.Boolean(), nullable=True), + sa.Column('acts_as_cd', sa.Boolean(), nullable=True), + sa.Column('pedimento_arrived', sa.Boolean(), nullable=True), + sa.Column('green_light_mx', sa.Boolean(), nullable=True), + sa.Column('green_light_us', sa.Boolean(), nullable=True), + sa.Column('red_light_mx', sa.Boolean(), nullable=True), + sa.Column('red_light_us', sa.Boolean(), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['carrier_int_id'], ['a76.transporter.transporter_id'], ), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['invoice_id'], ['a76.invoice_header.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.ForeignKeyConstraint(['trailer_int_id'], ['a76.trailer.trailer_id'], ), + sa.ForeignKeyConstraint(['transport_int_id'], ['a76.vehicle.vehicle_id'], ), + sa.PrimaryKeyConstraint('id'), + schema='a76' + ) + op.create_index(op.f('ix_a76_invoice_logistics_company_id'), 'invoice_logistics', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_invoice_logistics_tenant_id'), 'invoice_logistics', ['tenant_id'], unique=False, schema='a76') + op.create_table('invoice_sales_details', + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('invoice_id', sa.BigInteger(), nullable=False), + sa.Column('line_number', sa.Integer(), nullable=False), + sa.Column('sales_order', sa.String(length=20), nullable=True), + sa.Column('colors_description', sa.String(length=49), nullable=True), + sa.Column('square_color_code', sa.String(length=1), nullable=True), + sa.Column('line_bundles', sa.Integer(), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['invoice_id'], ['a76.invoice_header.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + schema='a76' + ) + op.create_index(op.f('ix_a76_invoice_sales_details_company_id'), 'invoice_sales_details', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_invoice_sales_details_tenant_id'), 'invoice_sales_details', ['tenant_id'], unique=False, schema='a76') + op.create_table('pedimentos', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('year', sa.String(length=2), nullable=False), + sa.Column('customs_office', sa.String(length=3), nullable=False), + sa.Column('license', sa.String(length=4), nullable=False), + sa.Column('pedimento_number', sa.String(length=7), nullable=False), + sa.Column('client_id', sa.Integer(), nullable=True), + sa.Column('operation_type', sa.String(length=3), nullable=False), + sa.Column('pedimento_type', sa.String(length=20), nullable=False), + sa.Column('pedimento_code', sa.String(length=2), nullable=False), + sa.Column('regime', sa.String(length=3), nullable=False), + sa.Column('status', sa.String(length=30), nullable=True), + sa.Column('usd_value', sa.Numeric(precision=17, scale=6), nullable=True), + sa.Column('paid_price', sa.Numeric(precision=17, scale=6), nullable=True), + sa.Column('gross_weight', sa.Numeric(precision=19, scale=3), nullable=True), + sa.Column('exchange_rate', sa.Numeric(precision=9, scale=5), nullable=True), + sa.Column('observations', sa.Text(), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['client_id'], ['a76.clients_and_providers.id'], name='fk_pedimentos_client'), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['pedimento_code'], ['public.pedimento_codes.code'], name='fk_pedimentos_code'), + sa.ForeignKeyConstraint(['regime'], ['public.pedimento_regimens.code'], name='fk_pedimentos_regime'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='pedimentos_pkey'), + sa.UniqueConstraint('tenant_id', 'company_id', 'year', 'customs_office', 'license', 'pedimento_number', name='pedimentos_unique_key'), + schema='a76' + ) + op.create_index('idx_pedimentos_client_id', 'pedimentos', ['client_id'], unique=False, schema='a76') + op.create_index('idx_pedimentos_created_at', 'pedimentos', ['created_at'], unique=False, schema='a76') + op.create_index('idx_pedimentos_status', 'pedimentos', ['status'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_pedimentos_company_id'), 'pedimentos', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_pedimentos_tenant_id'), 'pedimentos', ['tenant_id'], unique=False, schema='a76') + op.create_table('unit_conversions', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('from_unit_code', sa.String(length=5), nullable=False), + sa.Column('to_unit_code', sa.String(length=5), nullable=False), + sa.Column('conversion_factor', sa.Numeric(precision=13, scale=6), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['from_unit_code', 'tenant_id', 'company_id'], ['a76.units_of_measure.code', 'a76.units_of_measure.tenant_id', 'a76.units_of_measure.company_id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.ForeignKeyConstraint(['to_unit_code', 'tenant_id', 'company_id'], ['a76.units_of_measure.code', 'a76.units_of_measure.tenant_id', 'a76.units_of_measure.company_id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('from_unit_code', 'to_unit_code', 'tenant_id', 'company_id', name='uq_unit_conversion_pair'), + schema='a76' + ) + op.create_index(op.f('ix_a76_unit_conversions_company_id'), 'unit_conversions', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_unit_conversions_tenant_id'), 'unit_conversions', ['tenant_id'], unique=False, schema='a76') + op.create_table('role_permissions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('company_role_id', sa.Integer(), nullable=False), + sa.Column('permission_id', sa.Integer(), nullable=False), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['company_role_id'], ['core.company_roles.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['permission_id'], ['core.permissions.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('company_role_id', 'permission_id', name='uq_role_permission'), + schema='core' + ) + op.create_index(op.f('ix_core_role_permissions_company_id'), 'role_permissions', ['company_id'], unique=False, schema='core') + op.create_index(op.f('ix_core_role_permissions_company_role_id'), 'role_permissions', ['company_role_id'], unique=False, schema='core') + op.create_index(op.f('ix_core_role_permissions_id'), 'role_permissions', ['id'], unique=False, schema='core') + op.create_index(op.f('ix_core_role_permissions_permission_id'), 'role_permissions', ['permission_id'], unique=False, schema='core') + op.create_index(op.f('ix_core_role_permissions_tenant_id'), 'role_permissions', ['tenant_id'], unique=False, schema='core') + op.create_index('ix_role_permissions_composite', 'role_permissions', ['company_role_id', 'permission_id'], unique=False, schema='core') + op.create_table('user_company_roles', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.String(length=100), nullable=False), + sa.Column('company_role_id', sa.Integer(), nullable=False), + sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False), + sa.Column('assigned_by', sa.String(length=100), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['company_role_id'], ['core.company_roles.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id', 'company_id', 'tenant_id', 'company_role_id', name='uq_user_company_role'), + schema='core' + ) + op.create_index(op.f('ix_core_user_company_roles_company_id'), 'user_company_roles', ['company_id'], unique=False, schema='core') + op.create_index(op.f('ix_core_user_company_roles_company_role_id'), 'user_company_roles', ['company_role_id'], unique=False, schema='core') + op.create_index(op.f('ix_core_user_company_roles_id'), 'user_company_roles', ['id'], unique=False, schema='core') + op.create_index(op.f('ix_core_user_company_roles_tenant_id'), 'user_company_roles', ['tenant_id'], unique=False, schema='core') + op.create_index(op.f('ix_core_user_company_roles_user_id'), 'user_company_roles', ['user_id'], unique=False, schema='core') + op.create_index('ix_user_company_roles_user_company', 'user_company_roles', ['user_id', 'company_id', 'tenant_id', 'is_active'], unique=False, schema='core') + op.create_table('fa_classes', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('class_id', sa.Integer(), nullable=False), + sa.Column('import_tariff_code', sa.String(length=10), nullable=True), + sa.Column('import_tariff_type', sa.String(length=6), nullable=True), + sa.Column('export_tariff_code', sa.String(length=10), nullable=True), + sa.Column('export_tariff_type', sa.String(length=6), nullable=True), + sa.Column('depreciation_rate', sa.Numeric(precision=5, scale=2), nullable=True), + sa.Column('fda_code', sa.String(length=20), nullable=True), + sa.Column('eccn_code', sa.String(length=20), nullable=True), + sa.Column('class_enabled', sa.Boolean(), server_default='true', nullable=False), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['class_id'], ['a76.classes.id'], name='fk_qclasses_classes'), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='qclases_pk'), + schema='a24' + ) + op.create_index(op.f('ix_a24_fa_classes_company_id'), 'fa_classes', ['company_id'], unique=False, schema='a24') + op.create_index(op.f('ix_a24_fa_classes_tenant_id'), 'fa_classes', ['tenant_id'], unique=False, schema='a24') + op.create_table('inv_classes', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('class_id', sa.Integer(), nullable=False), + sa.Column('stock_um', sa.String(length=5), nullable=False), + sa.Column('us_tariff_code', sa.String(length=19), nullable=False), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['class_id'], ['a76.classes.id'], name='fk_sclasses_classes'), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='sclases_pk'), + schema='a24' + ) + op.create_index(op.f('ix_a24_inv_classes_company_id'), 'inv_classes', ['company_id'], unique=False, schema='a24') + op.create_index(op.f('ix_a24_inv_classes_tenant_id'), 'inv_classes', ['tenant_id'], unique=False, schema='a24') + op.create_table('doda_container_seals', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('container_id', sa.Integer(), nullable=False), + sa.Column('doda_id', sa.Integer(), nullable=False), + sa.Column('seal_line', sa.Integer(), nullable=False), + sa.Column('seal_value', sa.String(length=21), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['container_id'], ['a76.doda_containers.id'], name='fk_doda_container_seals_container'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='doda_container_seals_pkey'), + schema='a76' + ) + op.create_index(op.f('ix_a76_doda_container_seals_company_id'), 'doda_container_seals', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_doda_container_seals_tenant_id'), 'doda_container_seals', ['tenant_id'], unique=False, schema='a76') + op.create_table('invoice_compliance_mx', + sa.Column('invoice_id', sa.BigInteger(), nullable=False), + sa.Column('pedimento_id', sa.Integer(), nullable=True), + sa.Column('pedimento_r1', sa.Integer(), nullable=True), + sa.Column('pedimento_k1', sa.Integer(), nullable=True), + sa.Column('remesa', sa.Integer(), nullable=True), + sa.Column('aduana', sa.String(length=3), nullable=True), + sa.Column('port_of_entry', sa.String(length=6), nullable=True), + sa.Column('destination', sa.String(length=3), nullable=True), + sa.Column('manifest_number', sa.String(length=15), nullable=True), + sa.Column('provider_header', sa.String(length=20), nullable=True), + sa.Column('provider_id', sa.Integer(), nullable=True), + sa.Column('sold_to_header', sa.String(length=20), nullable=True), + sa.Column('sold_to_id', sa.Integer(), nullable=True), + sa.Column('shipped_to_header', sa.String(length=20), nullable=True), + sa.Column('shipped_to_id', sa.Integer(), nullable=True), + sa.Column('shipped_by_header', sa.String(length=20), nullable=True), + sa.Column('shipped_by_id', sa.Integer(), nullable=True), + sa.Column('customs_broker_id', sa.Integer(), nullable=True), + sa.Column('customs_broker_us_id', sa.Integer(), nullable=True), + sa.Column('broker_invoice_num', sa.String(length=20), nullable=True), + sa.Column('broker_invoice_date', sa.Date(), nullable=True), + sa.Column('is_mixed', sa.Boolean(), nullable=True), + sa.Column('waste_type', sa.String(length=1), nullable=True), + sa.Column('scrap_type', sa.String(length=1), nullable=True), + sa.Column('appendix_17', sa.Integer(), nullable=True), + sa.Column('is_regime_change', sa.Boolean(), server_default='false', nullable=True), + sa.Column('which_exchange_rate', sa.String(length=5), nullable=True), + sa.Column('value_method', sa.String(length=2), nullable=True), + sa.Column('act_value', sa.String(length=5), nullable=True), + sa.Column('rule_3121_parties_ii', sa.Boolean(), server_default='false', nullable=True), + sa.Column('is_pedimento_pending', sa.Boolean(), server_default='false', nullable=True), + sa.Column('is_owner_of_goods', sa.Boolean(), server_default='false', nullable=True), + sa.Column('generate_balances', sa.Boolean(), server_default='false', nullable=True), + sa.Column('was_reviewed_by_company', sa.Boolean(), nullable=True), + sa.Column('edocument', sa.String(length=50), nullable=True), + sa.Column('electronic_signature', sa.String(length=999), nullable=True), + sa.Column('certificate_number', sa.String(length=99), nullable=True), + sa.Column('niu_number', sa.String(length=19), nullable=True), + sa.Column('bill_of_lading_count', sa.String(length=12), nullable=True), + sa.Column('addendum_vu', sa.String(length=204), nullable=True), + sa.Column('origin_destination_cove', sa.String(length=20), nullable=True), + sa.Column('vucem_operation_num', sa.String(length=19), nullable=True), + sa.Column('customs_person_line', sa.Integer(), nullable=True), + sa.Column('contingency_mode', sa.Boolean(), nullable=True), + sa.Column('enclosure', sa.String(length=4), nullable=True), + sa.Column('guide_type_to_identify', sa.String(length=1), nullable=True), + sa.Column('location', sa.String(length=200), nullable=True), + sa.Column('dot_code', sa.String(length=20), nullable=True), + sa.Column('subdivision', sa.String(length=20), nullable=True), + sa.Column('acts_as', sa.String(length=20), nullable=True), + sa.Column('movement_type', sa.String(length=31), nullable=True), + sa.Column('office_document', sa.String(length=30), nullable=True), + sa.Column('reason_export', sa.String(length=1), nullable=True), + sa.Column('signature_key', sa.String(length=100), nullable=True), + sa.Column('sem_id', sa.Integer(), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['aduana'], ['public.customs_sections.customs_code'], ), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['customs_broker_id'], ['a76.customs_brokers.id'], ), + sa.ForeignKeyConstraint(['customs_broker_us_id'], ['a76.customs_brokers.id'], ), + sa.ForeignKeyConstraint(['invoice_id'], ['a76.invoice_header.id'], ), + sa.ForeignKeyConstraint(['pedimento_id'], ['a76.pedimentos.id'], ), + sa.ForeignKeyConstraint(['pedimento_k1'], ['a76.pedimentos.id'], ), + sa.ForeignKeyConstraint(['pedimento_r1'], ['a76.pedimentos.id'], ), + sa.ForeignKeyConstraint(['provider_id'], ['a76.clients_and_providers.id'], ), + sa.ForeignKeyConstraint(['shipped_by_id'], ['a76.clients_and_providers.id'], ), + sa.ForeignKeyConstraint(['shipped_to_id'], ['a76.clients_and_providers.id'], ), + sa.ForeignKeyConstraint(['sold_to_id'], ['a76.clients_and_providers.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('invoice_id'), + schema='a76' + ) + op.create_index(op.f('ix_a76_invoice_compliance_mx_company_id'), 'invoice_compliance_mx', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_invoice_compliance_mx_tenant_id'), 'invoice_compliance_mx', ['tenant_id'], unique=False, schema='a76') + op.create_table('parts', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('part_number', sa.String(length=70), nullable=False), + sa.Column('commercial_part_number', sa.String(length=70), nullable=True), + sa.Column('description_spanish', sa.String(length=500), nullable=True), + sa.Column('description_english', sa.String(length=500), nullable=True), + sa.Column('part_class', sa.String(length=8), nullable=True), + sa.Column('unit_of_measure', sa.String(length=5), nullable=True), + sa.Column('unit_cost', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('currency_type', sa.String(length=2), nullable=True), + sa.Column('currency_key', sa.String(length=3), nullable=True), + sa.Column('unit_weight', sa.Numeric(precision=19, scale=8), nullable=True), + sa.Column('weight_type', sa.String(length=6), nullable=True), + sa.Column('fraction', sa.String(length=10), nullable=True), + sa.Column('us_fraction', sa.String(length=16), nullable=True), + sa.Column('fda_key', sa.String(length=20), nullable=True), + sa.Column('fcc_key', sa.String(length=30), nullable=True), + sa.Column('license_code', sa.String(length=3), nullable=True), + sa.Column('eccn', sa.String(length=20), nullable=True), + sa.Column('export_code', sa.String(length=2), nullable=True), + sa.Column('exclusion_symbol', sa.String(length=19), nullable=True), + sa.Column('is_active', sa.Boolean(), server_default='true', nullable=True), + sa.Column('part_photo', sa.String(length=255), nullable=True), + sa.Column('creation_date', sa.Integer(), nullable=True), + sa.Column('modification_date', sa.Integer(), nullable=True), + sa.Column('modification_date_iso', sa.DateTime(), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['currency_key'], ['public.currency_types.code'], name='fk_parts_currency'), + sa.ForeignKeyConstraint(['part_class', 'tenant_id', 'company_id'], ['a76.classes.class_code', 'a76.classes.tenant_id', 'a76.classes.company_id'], name='fk_parts_class'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.ForeignKeyConstraint(['unit_of_measure', 'tenant_id', 'company_id'], ['a76.units_of_measure.code', 'a76.units_of_measure.tenant_id', 'a76.units_of_measure.company_id'], ), + sa.PrimaryKeyConstraint('id', name='parts_pkey'), + sa.UniqueConstraint('tenant_id', 'company_id', 'part_number', name='client_part_ukey'), + schema='a76' + ) + op.create_index(op.f('ix_a76_parts_company_id'), 'parts', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_parts_tenant_id'), 'parts', ['tenant_id'], unique=False, schema='a76') + op.create_table('pedimento_config_additional', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('pedimento_id', sa.Integer(), nullable=False), + sa.Column('add_po_identifier', sa.Boolean(), nullable=False), + sa.Column('do_not_exempt_norms_complement_x', sa.Boolean(), nullable=False), + sa.Column('manual_pedimento_year', sa.Integer(), nullable=True), + sa.Column('enable_import_invoice_recipient', sa.Boolean(), nullable=False), + sa.Column('send_502_validation_file_for_consolidated', sa.Boolean(), nullable=False), + sa.Column('add_remove_norms', sa.Boolean(), nullable=False), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['pedimento_id'], ['a76.pedimentos.id'], name='fk_pedimento_config_additional', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='pedimento_config_additional_pkey'), + sa.UniqueConstraint('tenant_id', 'company_id', 'pedimento_id', name='pedimento_config_additional_pedimento_id_key'), + schema='a76' + ) + op.create_index(op.f('ix_a76_pedimento_config_additional_company_id'), 'pedimento_config_additional', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_pedimento_config_additional_tenant_id'), 'pedimento_config_additional', ['tenant_id'], unique=False, schema='a76') + op.create_table('pedimento_config_calculations', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('pedimento_id', sa.Integer(), nullable=False), + sa.Column('dta_type', sa.String(length=1), nullable=True), + sa.Column('dta_operation', sa.Boolean(), server_default='false', nullable=False), + sa.Column('dta_vehicle_count', sa.SmallInteger(), server_default='0', nullable=False), + sa.Column('dta_mixed_rate_8permil', sa.Boolean(), server_default='false', nullable=False), + sa.Column('pays_vat', sa.Boolean(), server_default='false', nullable=False), + sa.Column('pays_prevalidation', sa.Boolean(), server_default='false', nullable=False), + sa.Column('include_sagar_certificate_fee', sa.Boolean(), server_default='false', nullable=False), + sa.Column('fixed_vehicle_dta_fee', sa.Boolean(), server_default='false', nullable=False), + sa.Column('additional_fixed_fee', sa.SmallInteger(), server_default='0', nullable=False), + sa.Column('additional_fixed_fee_payment_method', sa.SmallInteger(), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['pedimento_id'], ['a76.pedimentos.id'], name='fk_pedimento_config_calculations', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='pedimento_config_calculations_pkey'), + sa.UniqueConstraint('tenant_id', 'company_id', 'pedimento_id', name='pedimento_config_calculations_pedimento_id_key'), + schema='a76' + ) + op.create_index(op.f('ix_a76_pedimento_config_calculations_company_id'), 'pedimento_config_calculations', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_pedimento_config_calculations_tenant_id'), 'pedimento_config_calculations', ['tenant_id'], unique=False, schema='a76') + op.create_table('pedimento_config_parameters', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('pedimento_id', sa.Integer(), nullable=False), + sa.Column('is_embassy', sa.Boolean(), server_default='false', nullable=False), + sa.Column('embassy_dta', sa.Numeric(precision=11, scale=2), server_default='0.00', nullable=False), + sa.Column('rule_3121_section_ii', sa.Boolean(), server_default='false', nullable=False), + sa.Column('use_previous_tariff', sa.Boolean(), server_default='false', nullable=False), + sa.Column('use_payment_date_fi', sa.Boolean(), server_default='false', nullable=False), + sa.Column('add_state_supplier_record_505', sa.Boolean(), server_default='false', nullable=False), + sa.Column('customs_value_calculation', sa.Boolean(), server_default='false', nullable=False), + sa.Column('two_decimals_unit_value', sa.Boolean(), server_default='false', nullable=False), + sa.Column('customs_value_per_item', sa.Boolean(), server_default='false', nullable=False), + sa.Column('is_national_supplier', sa.Boolean(), server_default='false', nullable=False), + sa.Column('is_consolidated', sa.Boolean(), server_default='false', nullable=False), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['pedimento_id'], ['a76.pedimentos.id'], name='fk_pedimento_config_parameters', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='pedimento_config_parameters_pkey'), + sa.UniqueConstraint('tenant_id', 'company_id', 'pedimento_id', name='pedimento_config_parameters_pedimento_id_key'), + schema='a76' + ) + op.create_index(op.f('ix_a76_pedimento_config_parameters_company_id'), 'pedimento_config_parameters', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_pedimento_config_parameters_tenant_id'), 'pedimento_config_parameters', ['tenant_id'], unique=False, schema='a76') + op.create_table('pedimento_config_surcharges', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('pedimento_id', sa.Integer(), nullable=False), + sa.Column('surcharge_igi', sa.Boolean(), nullable=False), + sa.Column('surcharge_dta', sa.Boolean(), nullable=False), + sa.Column('surcharge_vat', sa.Boolean(), nullable=False), + sa.Column('surcharge_isan', sa.Boolean(), nullable=False), + sa.Column('surcharge_ieps', sa.Boolean(), nullable=False), + sa.Column('surcharge_cc', sa.Boolean(), nullable=False), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['pedimento_id'], ['a76.pedimentos.id'], name='fk_pedimento_config_surcharges', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='pedimento_config_surcharges_pkey'), + sa.UniqueConstraint('tenant_id', 'company_id', 'pedimento_id', name='pedimento_config_surcharges_pedimento_id_key'), + schema='a76' + ) + op.create_index(op.f('ix_a76_pedimento_config_surcharges_company_id'), 'pedimento_config_surcharges', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_pedimento_config_surcharges_tenant_id'), 'pedimento_config_surcharges', ['tenant_id'], unique=False, schema='a76') + op.create_table('pedimento_config_update_rectification', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('pedimento_id', sa.Integer(), nullable=False), + sa.Column('update_vat', sa.Boolean(), server_default='false', nullable=False), + sa.Column('update_advalorem', sa.Boolean(), server_default='false', nullable=False), + sa.Column('update_dta', sa.Boolean(), server_default='false', nullable=False), + sa.Column('update_cc', sa.Boolean(), server_default='false', nullable=False), + sa.Column('update_ieps', sa.Boolean(), server_default='false', nullable=False), + sa.Column('calculate_surcharge', sa.Boolean(), server_default='false', nullable=False), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['pedimento_id'], ['a76.pedimentos.id'], name='fk_pedimento_config_update_rectification', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='pedimento_config_update_rectification_pkey'), + sa.UniqueConstraint('tenant_id', 'company_id', 'pedimento_id', name='pedimento_config_update_rectification_pedimento_id_key'), + schema='a76' + ) + op.create_index(op.f('ix_a76_pedimento_config_update_rectification_company_id'), 'pedimento_config_update_rectification', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_pedimento_config_update_rectification_tenant_id'), 'pedimento_config_update_rectification', ['tenant_id'], unique=False, schema='a76') + op.create_table('pedimento_config_updates', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('pedimento_id', sa.Integer(), nullable=False), + sa.Column('update_vat', sa.Boolean(), server_default='false', nullable=False), + sa.Column('update_advalorem', sa.Boolean(), server_default='false', nullable=False), + sa.Column('update_dta', sa.Boolean(), server_default='false', nullable=False), + sa.Column('update_cc', sa.Boolean(), server_default='false', nullable=False), + sa.Column('update_ieps', sa.Boolean(), server_default='false', nullable=False), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['pedimento_id'], ['a76.pedimentos.id'], name='fk_pedimento_config_updates', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='pedimento_config_updates_pkey'), + sa.UniqueConstraint('tenant_id', 'company_id', 'pedimento_id', name='pedimento_config_updates_pedimento_id_key'), + schema='a76' + ) + op.create_index(op.f('ix_a76_pedimento_config_updates_company_id'), 'pedimento_config_updates', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_pedimento_config_updates_tenant_id'), 'pedimento_config_updates', ['tenant_id'], unique=False, schema='a76') + op.create_table('pedimento_containers', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('pedimento_id', sa.Integer(), nullable=False), + sa.Column('number', sa.String(length=100), nullable=True), + sa.Column('identification', sa.String(length=100), nullable=True), + sa.Column('type', sa.String(length=50), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['pedimento_id'], ['a76.pedimentos.id'], name='fk_pedimento_containers', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='pedimento_containers_pkey'), + schema='a76' + ) + op.create_index(op.f('ix_a76_pedimento_containers_company_id'), 'pedimento_containers', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_pedimento_containers_tenant_id'), 'pedimento_containers', ['tenant_id'], unique=False, schema='a76') + op.create_table('pedimento_contributions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('pedimento_id', sa.Integer(), nullable=False), + sa.Column('contribucion', sa.String(length=50), nullable=True), + sa.Column('tipo_tasa', sa.String(length=100), nullable=True), + sa.Column('tasa', sa.Numeric(precision=15, scale=8), nullable=True), + sa.Column('forma_pago', sa.String(length=50), nullable=True), + sa.Column('importe', sa.Numeric(precision=17, scale=2), nullable=True), + sa.Column('gravamen', sa.String(length=100), nullable=True), + sa.Column('abreviacion', sa.String(length=50), nullable=True), + sa.Column('forma_pago_2', sa.String(length=50), nullable=True), + sa.Column('importe_2', sa.Numeric(precision=17, scale=2), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['pedimento_id'], ['a76.pedimentos.id'], name='fk_pedimento_contributions', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='pedimento_contributions_pkey'), + schema='a76' + ) + op.create_index(op.f('ix_a76_pedimento_contributions_company_id'), 'pedimento_contributions', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_pedimento_contributions_tenant_id'), 'pedimento_contributions', ['tenant_id'], unique=False, schema='a76') + op.create_table('pedimento_customs_offices', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('pedimento_id', sa.Integer(), nullable=False), + sa.Column('dispatch_customs', sa.String(length=3), nullable=False), + sa.Column('entry_exit_customs', sa.String(length=3), nullable=False), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['pedimento_id'], ['a76.pedimentos.id'], name='fk_pedimento_customs_offices', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='pedimento_customs_offices_pkey'), + sa.UniqueConstraint('tenant_id', 'company_id', 'pedimento_id', name='pedimento_customs_offices_pedimento_id_key'), + schema='a76' + ) + op.create_index(op.f('ix_a76_pedimento_customs_offices_company_id'), 'pedimento_customs_offices', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_pedimento_customs_offices_tenant_id'), 'pedimento_customs_offices', ['tenant_id'], unique=False, schema='a76') + op.create_table('pedimento_dates', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('pedimento_id', sa.Integer(), nullable=False), + sa.Column('entry_date', sa.DateTime(), nullable=False), + sa.Column('pedimento_date', sa.DateTime(), nullable=True), + sa.Column('payment_date', sa.DateTime(), nullable=True), + sa.Column('rectification_payment_date', sa.DateTime(), nullable=True), + sa.Column('extraction_date', sa.DateTime(), nullable=True), + sa.Column('submission_date', sa.DateTime(), nullable=True), + sa.Column('eucan_date', sa.DateTime(), nullable=True), + sa.Column('original_date', sa.DateTime(), nullable=True), + sa.Column('start_date', sa.DateTime(), nullable=True), + sa.Column('end_date', sa.DateTime(), nullable=False), + sa.Column('capture_time', sa.Time(), nullable=False), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['pedimento_id'], ['a76.pedimentos.id'], name='fk_pedimento_dates', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='pedimento_dates_pkey'), + sa.UniqueConstraint('tenant_id', 'company_id', 'pedimento_id', name='pedimento_dates_pedimento_id_key'), + schema='a76' + ) + op.create_index('idx_pedimento_dates_pedimento_id', 'pedimento_dates', ['pedimento_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_pedimento_dates_company_id'), 'pedimento_dates', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_pedimento_dates_tenant_id'), 'pedimento_dates', ['tenant_id'], unique=False, schema='a76') + op.create_table('pedimento_decrementables', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('pedimento_id', sa.Integer(), nullable=False), + sa.Column('freight', sa.Numeric(precision=13, scale=2), nullable=True), + sa.Column('insurance', sa.Numeric(precision=13, scale=2), nullable=True), + sa.Column('loading', sa.Numeric(precision=13, scale=2), nullable=True), + sa.Column('unloading', sa.Numeric(precision=13, scale=2), nullable=True), + sa.Column('others', sa.Numeric(precision=13, scale=2), nullable=True), + sa.Column('currency', sa.String(length=3), nullable=True), + sa.Column('currency_factor', sa.Numeric(precision=15, scale=8), nullable=True), + sa.Column('not_affect_usd_value', sa.SmallInteger(), nullable=True), + sa.Column('not_affect_customs_value', sa.SmallInteger(), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['pedimento_id'], ['a76.pedimentos.id'], name='fk_pedimento_decrementables', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='pedimento_decrementables_pkey'), + sa.UniqueConstraint('tenant_id', 'company_id', 'pedimento_id', name='pedimento_decrementables_pedimento_id_key'), + schema='a76' + ) + op.create_index(op.f('ix_a76_pedimento_decrementables_company_id'), 'pedimento_decrementables', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_pedimento_decrementables_tenant_id'), 'pedimento_decrementables', ['tenant_id'], unique=False, schema='a76') + op.create_table('pedimento_guides', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('pedimento_id', sa.Integer(), nullable=False), + sa.Column('guide', sa.String(length=100), nullable=True), + sa.Column('identifier', sa.String(length=100), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['pedimento_id'], ['a76.pedimentos.id'], name='fk_pedimento_guides', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='pedimento_guides_pkey'), + schema='a76' + ) + op.create_index(op.f('ix_a76_pedimento_guides_company_id'), 'pedimento_guides', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_pedimento_guides_tenant_id'), 'pedimento_guides', ['tenant_id'], unique=False, schema='a76') + op.create_table('pedimento_incrementables', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('pedimento_id', sa.Integer(), nullable=False), + sa.Column('insured_value', sa.Numeric(precision=13, scale=2), nullable=True), + sa.Column('freight', sa.Numeric(precision=13, scale=2), nullable=True), + sa.Column('insurance', sa.Numeric(precision=13, scale=2), nullable=True), + sa.Column('packaging', sa.Numeric(precision=13, scale=2), nullable=True), + sa.Column('others', sa.Numeric(precision=13, scale=3), nullable=True), + sa.Column('deductibles', sa.Numeric(precision=13, scale=3), nullable=True), + sa.Column('currency', sa.String(length=3), nullable=True), + sa.Column('currency_factor', sa.Numeric(precision=15, scale=8), nullable=True), + sa.Column('not_affect_usd_value', sa.SmallInteger(), nullable=True), + sa.Column('not_affect_customs_value', sa.SmallInteger(), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['pedimento_id'], ['a76.pedimentos.id'], name='fk_pedimento_incrementables', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='pedimento_incrementables_pkey'), + sa.UniqueConstraint('tenant_id', 'company_id', 'pedimento_id', name='pedimento_incrementables_pedimento_id_key'), + schema='a76' + ) + op.create_index(op.f('ix_a76_pedimento_incrementables_company_id'), 'pedimento_incrementables', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_pedimento_incrementables_tenant_id'), 'pedimento_incrementables', ['tenant_id'], unique=False, schema='a76') + op.create_table('pedimento_indexes', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('pedimento_id', sa.Integer(), nullable=False), + sa.Column('update_factor_type', sa.SmallInteger(), nullable=True), + sa.Column('update_factor', sa.Numeric(precision=7, scale=4), nullable=True), + sa.Column('manual_update_factor', sa.SmallInteger(), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['pedimento_id'], ['a76.pedimentos.id'], name='fk_pedimento_indexes', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='pedimento_indexes_pkey'), + sa.UniqueConstraint('tenant_id', 'company_id', 'pedimento_id', name='pedimento_indexes_pedimento_id_key'), + schema='a76' + ) + op.create_index(op.f('ix_a76_pedimento_indexes_company_id'), 'pedimento_indexes', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_pedimento_indexes_tenant_id'), 'pedimento_indexes', ['tenant_id'], unique=False, schema='a76') + op.create_table('pedimento_packages', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('pedimento_id', sa.Integer(), nullable=False), + sa.Column('quantity', sa.Integer(), nullable=True), + sa.Column('brand', sa.String(length=100), nullable=True), + sa.Column('number', sa.String(length=100), nullable=True), + sa.Column('vehicles', sa.Integer(), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['pedimento_id'], ['a76.pedimentos.id'], name='fk_pedimento_packages', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='pedimento_packages_pkey'), + sa.UniqueConstraint('tenant_id', 'company_id', 'pedimento_id', name='pedimento_packages_pedimento_id_key'), + schema='a76' + ) + op.create_index(op.f('ix_a76_pedimento_packages_company_id'), 'pedimento_packages', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_pedimento_packages_tenant_id'), 'pedimento_packages', ['tenant_id'], unique=False, schema='a76') + op.create_table('pedimento_payments', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('pedimento_id', sa.Integer(), nullable=False), + sa.Column('acknowledgment', sa.String(length=20), nullable=False), + sa.Column('operation_number', sa.String(length=14), nullable=False), + sa.Column('bank_code', sa.Integer(), nullable=False), + sa.Column('cashier', sa.String(length=2), nullable=False), + sa.Column('date', sa.Date(), nullable=False), + sa.Column('time', sa.Time(), nullable=False), + sa.Column('shift', sa.String(length=1), nullable=False), + sa.Column('total_cash_paid', sa.Integer(), nullable=False), + sa.Column('total_contributions', sa.Integer(), nullable=False), + sa.Column('counter_payment', sa.SmallInteger(), nullable=False), + sa.Column('pece_code', sa.String(length=5), nullable=False), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['pedimento_id'], ['a76.pedimentos.id'], name='fk_pedimento_payments', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='pedimento_payments_pkey'), + sa.UniqueConstraint('tenant_id', 'company_id', 'pedimento_id', name='pedimento_payments_pedimento_id_key'), + schema='a76' + ) + op.create_index('idx_pedimento_payments_pedimento_id', 'pedimento_payments', ['pedimento_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_pedimento_payments_company_id'), 'pedimento_payments', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_pedimento_payments_tenant_id'), 'pedimento_payments', ['tenant_id'], unique=False, schema='a76') + op.create_table('pedimento_rectification_destination', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('pedimento_id', sa.Integer(), nullable=False), + sa.Column('destination_pedimento_year', sa.String(length=2), nullable=False), + sa.Column('destination_customs_office', sa.String(length=3), nullable=False), + sa.Column('destination_license', sa.String(length=4), nullable=False), + sa.Column('destination_pedimento_number', sa.String(length=7), nullable=False), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['pedimento_id'], ['a76.pedimentos.id'], name='fk_pedimento_rectification_destination', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='pedimento_rectification_destination_pkey'), + sa.UniqueConstraint('tenant_id', 'company_id', 'pedimento_id', name='pedimento_rectification_destination_pedimento_id_key'), + schema='a76' + ) + op.create_index(op.f('ix_a76_pedimento_rectification_destination_company_id'), 'pedimento_rectification_destination', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_pedimento_rectification_destination_tenant_id'), 'pedimento_rectification_destination', ['tenant_id'], unique=False, schema='a76') + op.create_table('pedimento_rectification_origin', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('pedimento_id', sa.Integer(), nullable=False), + sa.Column('original_pedimento_year', sa.String(length=2), nullable=True), + sa.Column('original_customs_office', sa.String(length=3), nullable=True), + sa.Column('original_license', sa.String(length=4), nullable=True), + sa.Column('original_pedimento_number', sa.String(length=7), nullable=True), + sa.Column('original_pedimento_code', sa.String(length=2), nullable=True), + sa.Column('original_payment_date', sa.DateTime(), nullable=True), + sa.Column('total_cash', sa.Integer(), nullable=True), + sa.Column('total_others', sa.Integer(), nullable=True), + sa.Column('reason', sa.String(length=255), nullable=True), + sa.Column('charge_to_client', sa.SmallInteger(), nullable=True), + sa.Column('use_original_payment_date_for_interest_calc', sa.SmallInteger(), nullable=True), + sa.Column('manual_calculation', sa.SmallInteger(), nullable=True), + sa.Column('original_pedimento_norms', sa.SmallInteger(), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['pedimento_id'], ['a76.pedimentos.id'], name='fk_pedimento_rectification_origin', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='pedimento_rectification_origin_pkey'), + sa.UniqueConstraint('tenant_id', 'company_id', 'pedimento_id', name='pedimento_rectification_origin_pedimento_id_key'), + schema='a76' + ) + op.create_index(op.f('ix_a76_pedimento_rectification_origin_company_id'), 'pedimento_rectification_origin', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_pedimento_rectification_origin_tenant_id'), 'pedimento_rectification_origin', ['tenant_id'], unique=False, schema='a76') + op.create_table('pedimento_seals', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('pedimento_id', sa.Integer(), nullable=False), + sa.Column('number', sa.String(length=100), nullable=True), + sa.Column('identification', sa.String(length=100), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['pedimento_id'], ['a76.pedimentos.id'], name='fk_pedimento_seals', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='pedimento_seals_pkey'), + schema='a76' + ) + op.create_index(op.f('ix_a76_pedimento_seals_company_id'), 'pedimento_seals', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_pedimento_seals_tenant_id'), 'pedimento_seals', ['tenant_id'], unique=False, schema='a76') + op.create_table('pedimento_transport_carriers', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('pedimento_id', sa.Integer(), nullable=False), + sa.Column('carrier', sa.String(length=200), nullable=True), + sa.Column('rfc', sa.String(length=20), nullable=True), + sa.Column('curp', sa.String(length=20), nullable=True), + sa.Column('name', sa.String(length=200), nullable=True), + sa.Column('address', sa.String(length=300), nullable=True), + sa.Column('city', sa.String(length=100), nullable=True), + sa.Column('state', sa.String(length=100), nullable=True), + sa.Column('country', sa.String(length=3), nullable=True), + sa.Column('tax_id', sa.String(length=50), nullable=True), + sa.Column('total_packages', sa.Integer(), nullable=True), + sa.Column('identification', sa.String(length=100), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['pedimento_id'], ['a76.pedimentos.id'], name='fk_pedimento_transport_carriers', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='pedimento_transport_carriers_pkey'), + schema='a76' + ) + op.create_index(op.f('ix_a76_pedimento_transport_carriers_company_id'), 'pedimento_transport_carriers', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_pedimento_transport_carriers_tenant_id'), 'pedimento_transport_carriers', ['tenant_id'], unique=False, schema='a76') + op.create_table('pedimento_transport_means', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('pedimento_id', sa.Integer(), nullable=False), + sa.Column('destination', sa.SmallInteger(), nullable=False), + sa.Column('entry_exit', sa.String(length=3), nullable=False), + sa.Column('arrival', sa.String(length=3), nullable=False), + sa.Column('departure', sa.String(length=3), nullable=False), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['pedimento_id'], ['a76.pedimentos.id'], name='fk_pedimento_transport_means', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='pedimento_transport_means_pkey'), + sa.UniqueConstraint('tenant_id', 'company_id', 'pedimento_id', name='pedimento_transport_means_pedimento_id_key'), + schema='a76' + ) + op.create_index(op.f('ix_a76_pedimento_transport_means_company_id'), 'pedimento_transport_means', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_pedimento_transport_means_tenant_id'), 'pedimento_transport_means', ['tenant_id'], unique=False, schema='a76') + op.create_table('pedimento_validation', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('pedimento_id', sa.Integer(), nullable=False), + sa.Column('validator', sa.String(length=3), nullable=False), + sa.Column('validation_ack', sa.String(length=8), nullable=False), + sa.Column('pre_ack', sa.String(length=8), nullable=False), + sa.Column('line_signature', sa.String(length=50), nullable=False), + sa.Column('electronic_signature', sa.String(length=999), nullable=False), + sa.Column('certificate_number', sa.String(length=99), nullable=False), + sa.Column('validator_id', sa.Integer(), nullable=False), + sa.Column('responsible_id', sa.Integer(), nullable=False), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['pedimento_id'], ['a76.pedimentos.id'], name='fk_pedimento_validation', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='pedimento_validation_pkey'), + sa.UniqueConstraint('tenant_id', 'company_id', 'pedimento_id', name='pedimento_validation_pedimento_id_key'), + schema='a76' + ) + op.create_index(op.f('ix_a76_pedimento_validation_company_id'), 'pedimento_validation', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_pedimento_validation_tenant_id'), 'pedimento_validation', ['tenant_id'], unique=False, schema='a76') + op.create_table('fa_partes', + sa.Column('id', sa.Integer(), autoincrement=False, nullable=False), + sa.Column('origin_country', sa.String(length=3), nullable=True), + sa.Column('sector', sa.String(length=8), nullable=True), + sa.Column('fraction_type', sa.String(length=7), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['id'], ['a76.parts.id'], name='fk_fa_partes_master'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='fa_partes_pkey'), + schema='a24' + ) + op.create_index(op.f('ix_a24_fa_partes_company_id'), 'fa_partes', ['company_id'], unique=False, schema='a24') + op.create_index(op.f('ix_a24_fa_partes_tenant_id'), 'fa_partes', ['tenant_id'], unique=False, schema='a24') + op.create_table('inv_bom', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('parent_part_id', sa.Integer(), nullable=False), + sa.Column('component_part_id', sa.Integer(), nullable=False), + sa.Column('quantity', sa.Numeric(precision=19, scale=8), nullable=False), + sa.Column('uom_code', sa.String(length=5), nullable=False), + sa.Column('procedure_type', sa.String(length=10), nullable=True), + sa.Column('is_percentage', sa.Boolean(), nullable=False), + sa.Column('raw_material', sa.Numeric(precision=19, scale=8), nullable=True), + sa.Column('waste', sa.Numeric(precision=19, scale=8), nullable=True), + sa.Column('merma', sa.Numeric(precision=19, scale=8), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['component_part_id'], ['a76.parts.id'], name='fk_inv_bom_component'), + sa.ForeignKeyConstraint(['parent_part_id'], ['a76.parts.id'], name='fk_inv_bom_parent'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='inv_bom_pkey'), + schema='a24' + ) + op.create_index(op.f('ix_a24_inv_bom_company_id'), 'inv_bom', ['company_id'], unique=False, schema='a24') + op.create_index(op.f('ix_a24_inv_bom_tenant_id'), 'inv_bom', ['tenant_id'], unique=False, schema='a24') + op.create_table('inv_parte_paises', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('part_id', sa.Integer(), nullable=False), + sa.Column('country_code', sa.String(length=3), nullable=False), + sa.Column('fraction', sa.String(length=20), nullable=True), + sa.Column('is_origin', sa.Boolean(), nullable=False), + sa.Column('preference', sa.String(length=15), nullable=False), + sa.Column('has_certificate', sa.Boolean(), nullable=False), + sa.Column('certificate_number', sa.String(length=50), nullable=True), + sa.Column('end_date', sa.DateTime(), nullable=True), + sa.Column('previous_fractions_7m', sa.Boolean(), nullable=False), + sa.Column('omission_import', sa.Boolean(), nullable=False), + sa.Column('omission_export', sa.Boolean(), nullable=False), + sa.Column('import_percentage', sa.Numeric(precision=5, scale=2), nullable=True), + sa.Column('export_percentage', sa.Numeric(precision=5, scale=2), nullable=True), + sa.Column('sector', sa.String(length=10), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['part_id'], ['a76.parts.id'], name='fk_inv_parte_paises_part'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='inv_parte_paises_pkey'), + schema='a24' + ) + op.create_index(op.f('ix_a24_inv_parte_paises_company_id'), 'inv_parte_paises', ['company_id'], unique=False, schema='a24') + op.create_index(op.f('ix_a24_inv_parte_paises_tenant_id'), 'inv_parte_paises', ['tenant_id'], unique=False, schema='a24') + op.create_table('inv_partes', + sa.Column('id', sa.Integer(), autoincrement=False, nullable=False), + sa.Column('part_type', sa.String(length=10), nullable=True), + sa.Column('material_type', sa.String(length=10), nullable=True), + sa.Column('reference_number', sa.String(length=70), nullable=True), + sa.Column('flex_reference_number', sa.String(length=120), nullable=True), + sa.Column('equivalent_uom', sa.String(length=5), nullable=True), + sa.Column('conversion_factor', sa.Numeric(precision=19, scale=8), nullable=True), + sa.Column('stock_uom', sa.String(length=5), nullable=True), + sa.Column('alternate_uom', sa.String(length=5), nullable=True), + sa.Column('conversion_uom', sa.String(length=9), nullable=True), + sa.Column('added_value', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('added_value_type', sa.String(length=2), nullable=True), + sa.Column('assigned_client', sa.String(length=50), nullable=True), + sa.Column('supplier_code', sa.String(length=8), nullable=True), + sa.Column('is_textile', sa.String(length=2), nullable=True), + sa.Column('bom_version', sa.Integer(), nullable=True), + sa.Column('is_repair', sa.String(length=3), nullable=True), + sa.Column('is_hazardous', sa.String(length=1), nullable=True), + sa.Column('emergency_number', sa.String(length=30), nullable=True), + sa.Column('danger_class', sa.String(length=4), nullable=True), + sa.Column('packaging_group', sa.String(length=3), nullable=True), + sa.Column('width', sa.String(length=50), nullable=True), + sa.Column('thickness', sa.String(length=50), nullable=True), + sa.Column('specification', sa.String(length=50), nullable=True), + sa.Column('total_value', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('direct_labor', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('general_expenses', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('total_expenses', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('depreciation', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('tooling', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('material_consumed', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('profit', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('us_fraction_alt', sa.String(length=13), nullable=True), + sa.Column('ca_fraction', sa.String(length=13), nullable=True), + sa.Column('ad_valorem_us', sa.Numeric(precision=5, scale=2), nullable=True), + sa.Column('nafta_result', sa.String(length=19), nullable=True), + sa.Column('nafta_percentage', sa.Numeric(precision=5, scale=2), nullable=True), + sa.Column('dta', sa.String(length=19), nullable=True), + sa.Column('dtb', sa.String(length=19), nullable=True), + sa.Column('dtg', sa.String(length=19), nullable=True), + sa.Column('substitute_part', sa.String(length=70), nullable=True), + sa.Column('complementary_part', sa.String(length=70), nullable=True), + sa.Column('preference_part', sa.String(length=70), nullable=True), + sa.Column('use_alternate_quantity', sa.Boolean(), nullable=True), + sa.Column('un_number', sa.String(length=30), nullable=True), + sa.Column('shipping_name', sa.String(length=200), nullable=True), + sa.Column('hazard_notes', sa.String(length=500), nullable=True), + sa.Column('repair_unit_cost', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('repair_added_value', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('fraction_9801', sa.String(length=10), nullable=True), + sa.Column('immex_type', sa.String(length=10), nullable=True), + sa.Column('disable_movements', sa.Boolean(), nullable=True), + sa.Column('pga_program_code', sa.String(length=10), nullable=True), + sa.Column('usmca_fraction', sa.String(length=10), nullable=True), + sa.Column('scrap_part_number', sa.String(length=70), nullable=True), + sa.Column('waste_part_number', sa.String(length=70), nullable=True), + sa.Column('scrap_description_en', sa.String(length=500), nullable=True), + sa.Column('scrap_description_es', sa.String(length=500), nullable=True), + sa.Column('scrap_export_fraction', sa.String(length=10), nullable=True), + sa.Column('scrap_us_fraction', sa.String(length=10), nullable=True), + sa.Column('equivalent_uom_2', sa.String(length=5), nullable=True), + sa.Column('conversion_factor_2', sa.Numeric(precision=19, scale=8), nullable=True), + sa.Column('has_auxiliary', sa.Boolean(), nullable=True), + sa.Column('auxiliary_uom', sa.String(length=5), nullable=True), + sa.Column('auxiliary_conversion', sa.Numeric(precision=19, scale=8), nullable=True), + sa.Column('auxiliary_unit_cost', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('mex_packing', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('sales_order', sa.String(length=50), nullable=True), + sa.Column('use_rule_8', sa.Boolean(), nullable=True), + sa.Column('sector', sa.String(length=150), nullable=True), + sa.Column('origin_country', sa.String(length=3), nullable=True), + sa.Column('fraction_type', sa.String(length=10), nullable=True), + sa.Column('agency_code_definition', sa.String(length=50), nullable=True), + sa.Column('carta_porte', sa.String(length=100), nullable=True), + sa.Column('client_part_names', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('part_identifiers', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('substitute_parts', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('aphis_data', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('non_discharge_clients', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['id'], ['a76.parts.id'], name='fk_inv_partes_master'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='inv_partes_pkey'), + schema='a24' + ) + op.create_index(op.f('ix_a24_inv_partes_company_id'), 'inv_partes', ['company_id'], unique=False, schema='a24') + op.create_index(op.f('ix_a24_inv_partes_tenant_id'), 'inv_partes', ['tenant_id'], unique=False, schema='a24') + op.create_table('item_lines', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('invoice_id', sa.BigInteger(), nullable=False), + sa.Column('line_number', sa.Integer(), nullable=False), + sa.Column('part_number_id', sa.Integer(), nullable=True), + sa.Column('component_part_number_id', sa.Integer(), nullable=True), + sa.Column('class_id', sa.Integer(), nullable=True), + sa.Column('unit_of_measure', sa.Integer(), nullable=True), + sa.Column('alternate_unit', sa.Integer(), nullable=True), + sa.Column('uma_key', sa.String(length=2), nullable=True), + sa.Column('auxiliary_unit', sa.String(length=5), nullable=True), + sa.Column('permit_number', sa.String(length=20), nullable=True), + sa.Column('page_line', sa.String(length=10), nullable=True), + sa.Column('has_certificate', sa.Boolean(), nullable=True), + sa.Column('certificate_number', sa.String(length=10), nullable=True), + sa.Column('octave_permit', sa.String(length=20), nullable=True), + sa.Column('permits_ped', sa.String(length=500), nullable=True), + sa.Column('has_fda_code', sa.Boolean(), nullable=True), + sa.Column('fda_key', sa.String(length=10), nullable=True), + sa.Column('is_military_mcia', sa.Boolean(), nullable=True), + sa.Column('iv32_type_key', sa.String(length=5), nullable=True), + sa.Column('iv32_number', sa.String(length=35), nullable=True), + sa.Column('scrap_invoice', sa.String(length=15), nullable=True), + sa.Column('consecutive_destination', sa.Integer(), nullable=True), + sa.Column('ctm_section', sa.String(length=3), nullable=True), + sa.Column('tax_payment', sa.Boolean(), nullable=True), + sa.Column('payment_method', sa.String(length=9), nullable=True), + sa.Column('igi_amount', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('igi_payment_method', sa.String(length=9), nullable=True), + sa.Column('fcc_key', sa.String(length=30), nullable=True), + sa.Column('valuation_method', sa.String(length=2), nullable=True), + sa.Column('valuation_determined_value', sa.Numeric(precision=29, scale=8), nullable=True), + sa.Column('valuation_reason', sa.String(length=500), nullable=True), + sa.Column('container_rule', sa.String(length=50), nullable=True), + sa.Column('container_parts_ii', sa.String(length=50), nullable=True), + sa.Column('consecutive_aphis', sa.Integer(), nullable=True), + sa.Column('bom_version', sa.Integer(), nullable=True), + sa.Column('bill_version', sa.Integer(), nullable=True), + sa.Column('tlcan_value', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('identifier', sa.String(length=2), nullable=True), + sa.Column('validation_zero', sa.Integer(), nullable=True), + sa.Column('validation_one', sa.Integer(), nullable=True), + sa.Column('material_type', sa.String(length=50), nullable=True), + sa.Column('order_type', sa.String(length=50), nullable=True), + sa.Column('line_concept', sa.String(length=50), nullable=True), + sa.Column('review_dispatch', sa.String(length=10), nullable=True), + sa.Column('take_component_pt', sa.Integer(), nullable=True), + sa.Column('pallet2', sa.SmallInteger(), nullable=True), + sa.Column('wildcard_field', sa.String(length=100), nullable=True), + sa.Column('reference_number', sa.String(length=20), nullable=True), + sa.Column('order', sa.String(length=50), nullable=True), + sa.Column('guide_number', sa.String(length=50), nullable=True), + sa.Column('depreciation_date', sa.Date(), nullable=True), + sa.Column('rectification', sa.Boolean(), nullable=True), + sa.Column('warehouse', sa.String(length=30), nullable=True), + sa.Column('location', sa.String(length=200), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['alternate_unit'], ['a76.units_of_measure.id'], ), + sa.ForeignKeyConstraint(['class_id'], ['a76.classes.id'], ), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['component_part_number_id'], ['a76.parts.id'], ), + sa.ForeignKeyConstraint(['invoice_id'], ['a76.invoice_header.id'], ), + sa.ForeignKeyConstraint(['part_number_id'], ['a76.parts.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.ForeignKeyConstraint(['unit_of_measure'], ['a76.units_of_measure.id'], ), + sa.PrimaryKeyConstraint('id'), + schema='a76' + ) + op.create_index(op.f('ix_a76_item_lines_company_id'), 'item_lines', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_item_lines_tenant_id'), 'item_lines', ['tenant_id'], unique=False, schema='a76') + op.create_table('balance_movement', + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('import_invoice_id', sa.BigInteger(), nullable=False, comment='Import invoice (cabecera de importación)'), + sa.Column('import_item_line_id', sa.Integer(), nullable=False, comment='Import line item = the PEPS lot'), + sa.Column('part_number_id', sa.Integer(), nullable=True, comment='Denormalized from item_lines.part_number_id. Enables PEPS index without joins.'), + sa.Column('movement_type', sa.String(length=20), nullable=False, comment='See MovementType enum. Determines sign and whether qty counts as used.'), + sa.Column('quantity', sa.Numeric(precision=19, scale=8), nullable=False, comment='Always positive. Sign is inferred from movement_type via NEGATIVE_MOVEMENTS.'), + sa.Column('value_me', sa.Numeric(precision=23, scale=8), nullable=True, comment='USD'), + sa.Column('value_mn', sa.Numeric(precision=23, scale=8), nullable=True, comment='MXN'), + sa.Column('net_weight', sa.Numeric(precision=19, scale=8), nullable=True), + sa.Column('source_invoice_id', sa.BigInteger(), nullable=True, comment='Export / SM / CTM invoice. NULL for entries.'), + sa.Column('source_item_line_id', sa.Integer(), nullable=True, comment='Specific line in the export / SM / CTM invoice.'), + sa.Column('order_peps', sa.BigInteger(), nullable=False, comment='PEPS order within this lot. Lower = older = consumed first.'), + sa.Column('operation_date', sa.Date(), nullable=False, comment='Date of the actual business event, not DB insert.'), + sa.Column('notes', sa.String(length=300), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.CheckConstraint('quantity > 0', name='ck_balance_movement_qty_positive'), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['import_invoice_id'], ['a76.invoice_header.id'], ), + sa.ForeignKeyConstraint(['import_item_line_id'], ['a76.item_lines.id'], ), + sa.ForeignKeyConstraint(['part_number_id'], ['a76.parts.id'], ), + sa.ForeignKeyConstraint(['source_invoice_id'], ['a76.invoice_header.id'], ), + sa.ForeignKeyConstraint(['source_item_line_id'], ['a76.item_lines.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('import_item_line_id', 'order_peps', name='uq_balance_movement_lot_peps'), + schema='a24' + ) + op.create_index(op.f('ix_a24_balance_movement_company_id'), 'balance_movement', ['company_id'], unique=False, schema='a24') + op.create_index(op.f('ix_a24_balance_movement_tenant_id'), 'balance_movement', ['tenant_id'], unique=False, schema='a24') + op.create_index('ix_balmov_lot', 'balance_movement', ['import_item_line_id'], unique=False, schema='a24') + op.create_index('ix_balmov_operation_date', 'balance_movement', ['tenant_id', 'operation_date', 'movement_type'], unique=False, schema='a24') + op.create_index('ix_balmov_peps_lookup', 'balance_movement', ['tenant_id', 'part_number_id', 'movement_type', 'order_peps'], unique=False, schema='a24', postgresql_include=['import_item_line_id', 'quantity', 'value_me', 'value_mn']) + op.create_index('ix_balmov_source', 'balance_movement', ['source_invoice_id', 'source_item_line_id'], unique=False, schema='a24') + op.create_table('fa_item_lines', + sa.Column('id', sa.Integer(), autoincrement=False, nullable=False), + sa.Column('asset_number', sa.String(length=25), nullable=True), + sa.Column('asset_photo', sa.String(length=255), nullable=True), + sa.Column('equipment_message', sa.String(length=40), nullable=True), + sa.Column('invoice_type_asset', sa.String(length=6), nullable=True), + sa.Column('return_import_invoice', sa.String(length=15), nullable=True), + sa.Column('return_import_date', sa.Integer(), nullable=True), + sa.Column('movement_type_import', sa.String(length=3), nullable=True), + sa.Column('search_invoice', sa.String(length=15), nullable=True), + sa.Column('search_line', sa.Integer(), nullable=True), + sa.Column('search_type', sa.String(length=10), nullable=True), + sa.Column('is_subitem', sa.Boolean(), nullable=True), + sa.Column('contains_subitems', sa.Boolean(), nullable=True), + sa.Column('subitem_number', sa.Integer(), nullable=True), + sa.Column('discharge', sa.Boolean(), nullable=True), + sa.Column('own_equipment', sa.Boolean(), nullable=True), + sa.Column('omit_annex31', sa.Boolean(), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['id'], ['a76.item_lines.id'], name='fk_fa_item_lines_master'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='fa_item_lines_pkey'), + schema='a24' + ) + op.create_index(op.f('ix_a24_fa_item_lines_company_id'), 'fa_item_lines', ['company_id'], unique=False, schema='a24') + op.create_index(op.f('ix_a24_fa_item_lines_tenant_id'), 'fa_item_lines', ['tenant_id'], unique=False, schema='a24') + op.create_table('inv_aphis_general', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('inv_part_id', sa.Integer(), nullable=False), + sa.Column('program_code', sa.String(length=10), nullable=True), + sa.Column('processing_code', sa.String(length=10), nullable=True), + sa.Column('aphis_type', sa.String(length=10), nullable=True), + sa.Column('disclaimer', sa.String(length=10), nullable=True), + sa.Column('electronic_image', sa.String(length=50), nullable=True), + sa.Column('confidential', sa.String(length=1), nullable=True), + sa.Column('global_product_id', sa.String(length=100), nullable=True), + sa.Column('intended_use_code', sa.String(length=10), nullable=True), + sa.Column('intended_use_description', sa.String(length=200), nullable=True), + sa.Column('item_type', sa.String(length=20), nullable=True), + sa.Column('product_code', sa.String(length=20), nullable=True), + sa.Column('product_code_2', sa.String(length=20), nullable=True), + sa.Column('product_code_3', sa.String(length=20), nullable=True), + sa.Column('scientific_genus_name', sa.String(length=100), nullable=True), + sa.Column('scientific_species_name', sa.String(length=100), nullable=True), + sa.Column('scientific_sub_species_name', sa.String(length=100), nullable=True), + sa.Column('common_name_specific', sa.String(length=200), nullable=True), + sa.Column('common_name_general', sa.String(length=200), nullable=True), + sa.Column('signed_doc', sa.String(length=100), nullable=True), + sa.Column('signed_doc_date', sa.Date(), nullable=True), + sa.Column('signed_doc_id', sa.String(length=50), nullable=True), + sa.Column('invoice_number', sa.String(length=50), nullable=True), + sa.Column('quantity_1', sa.String(length=50), nullable=True), + sa.Column('quantity_2', sa.String(length=50), nullable=True), + sa.Column('quantity_3', sa.String(length=50), nullable=True), + sa.Column('inspection', sa.String(length=200), nullable=True), + sa.Column('inspection_date', sa.Date(), nullable=True), + sa.Column('inspection_loc_date', sa.Date(), nullable=True), + sa.Column('inspection_location', sa.String(length=200), nullable=True), + sa.Column('country_production', sa.String(length=3), nullable=True), + sa.Column('country_source', sa.String(length=3), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['inv_part_id'], ['a24.inv_partes.id'], name='fk_aphis_general_inv_part'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='inv_aphis_general_pkey'), + schema='a24' + ) + op.create_index(op.f('ix_a24_inv_aphis_general_company_id'), 'inv_aphis_general', ['company_id'], unique=False, schema='a24') + op.create_index(op.f('ix_a24_inv_aphis_general_tenant_id'), 'inv_aphis_general', ['tenant_id'], unique=False, schema='a24') + op.create_table('ctm_receipts', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('receipt_line', sa.Integer(), nullable=False), + sa.Column('option', sa.String(length=3), nullable=True), + sa.Column('exit_invoice', sa.String(length=19), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['receipt_line'], ['a76.item_lines.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + schema='a76' + ) + op.create_index(op.f('ix_a76_ctm_receipts_company_id'), 'ctm_receipts', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_ctm_receipts_tenant_id'), 'ctm_receipts', ['tenant_id'], unique=False, schema='a76') + op.create_table('identifier_details', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('invoice_consecutive', sa.Integer(), nullable=True), + sa.Column('part_line', sa.Integer(), nullable=True), + sa.Column('identifier_code', sa.String(length=2), nullable=True), + sa.Column('item_line_id', sa.Integer(), nullable=True), + sa.Column('module', sa.String(length=20), nullable=True), + sa.Column('complement1', sa.String(length=50), nullable=True), + sa.Column('complement2', sa.String(length=51), nullable=True), + sa.Column('complement3', sa.String(length=50), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['identifier_code'], ['a76.identifiers.code'], ), + sa.ForeignKeyConstraint(['item_line_id'], ['a76.item_lines.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + schema='a76' + ) + op.create_index(op.f('ix_a76_identifier_details_company_id'), 'identifier_details', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_identifier_details_tenant_id'), 'identifier_details', ['tenant_id'], unique=False, schema='a76') + op.create_table('item_line_customs', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('item_line_id', sa.Integer(), nullable=False), + sa.Column('fraction', sa.String(length=10), nullable=True), + sa.Column('fraction_type', sa.String(length=7), nullable=True), + sa.Column('american_fraction', sa.String(length=16), nullable=True), + sa.Column('alternate_fraction', sa.String(length=10), nullable=True), + sa.Column('reference_fraction', sa.String(length=10), nullable=True), + sa.Column('octave_fraction', sa.String(length=10), nullable=True), + sa.Column('tlcan_fraction', sa.String(length=13), nullable=True), + sa.Column('extra_american_fraction', sa.String(length=16), nullable=True), + sa.Column('garment_fraction', sa.String(length=19), nullable=True), + sa.Column('advalorem', sa.String(length=10), nullable=True), + sa.Column('advalorem_numeric', sa.Numeric(precision=7, scale=2), nullable=True), + sa.Column('advalorem_american', sa.Numeric(precision=5, scale=2), nullable=True), + sa.Column('advalorem_tlcan', sa.Numeric(precision=5, scale=2), nullable=True), + sa.Column('rate', sa.String(length=10), nullable=True), + sa.Column('depreciation_rate', sa.Numeric(precision=5, scale=2), nullable=True), + sa.Column('origin_country', sa.String(length=3), nullable=True), + sa.Column('destination_country', sa.String(length=3), nullable=True), + sa.Column('optional_country', sa.String(length=3), nullable=True), + sa.Column('origin_procedure', sa.String(length=3), nullable=True), + sa.Column('scrap_procedure', sa.String(length=3), nullable=True), + sa.Column('sector', sa.String(length=8), nullable=True), + sa.ForeignKeyConstraint(['item_line_id'], ['a76.item_lines.id'], ), + sa.PrimaryKeyConstraint('id'), + schema='a76' + ) + op.create_table('item_line_descriptions', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('item_line_id', sa.Integer(), nullable=False), + sa.Column('description_spanish', sa.String(length=4999), nullable=True), + sa.Column('description_english', sa.String(length=4999), nullable=True), + sa.Column('extra_description', sa.Text(), nullable=True), + sa.Column('part_description', sa.String(length=500), nullable=True), + sa.Column('class_description', sa.String(length=500), nullable=True), + sa.Column('package_description', sa.String(length=500), nullable=True), + sa.Column('brand', sa.String(length=50), nullable=True), + sa.Column('model', sa.String(length=50), nullable=True), + sa.Column('has_serial', sa.Boolean(), nullable=True), + sa.Column('additional_info_spanish', sa.String(length=1000), nullable=True), + sa.Column('additional_info_english', sa.String(length=1000), nullable=True), + sa.Column('lot', sa.String(length=254), nullable=True), + sa.Column('entry_number', sa.String(length=50), nullable=True), + sa.Column('eighth_rule_fraction', sa.String(length=20), nullable=True), + sa.Column('eighth_rule_line', sa.Integer(), nullable=True), + sa.Column('consider_a31', sa.Boolean(), nullable=True), + sa.Column('machinery_location', sa.String(length=200), nullable=True), + sa.ForeignKeyConstraint(['item_line_id'], ['a76.item_lines.id'], ), + sa.PrimaryKeyConstraint('id'), + schema='a76' + ) + op.create_table('item_line_financials', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('item_line_id', sa.Integer(), nullable=False), + sa.Column('unit_cost_capture', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('unit_cost_usd', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('unit_cost_commercial_usd', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('unit_cost_current_usd', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('unit_cost_depreciated_usd', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('unit_cost_subitem_usd', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('unit_cost_auxiliary_usd', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('sales_cost_usd', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('commercial_unit_cost', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('unit_cost_mxn', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('unit_cost_commercial_mxn', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('unit_cost_current_mxn', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('unit_cost_depreciated_mxn', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('unit_cost_subitem_mxn', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('sales_cost_mxn', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('unit_cost_mc', sa.Numeric(precision=29, scale=8), nullable=True), + sa.Column('value_mxn', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('value_commercial_mxn', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('value_updated_mxn', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('value_subitem_mxn', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('sub_import_value_mxn', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('value_returned_mxn', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('value_depreciated_mxn', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('customs_value_mxn', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('value_total_mxn', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('value_temp_material_mxn', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('value_def_material_mxn', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('value_added_mxn', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('value_national_packing_mxn', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('vat_mxn', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('vat_used_mxn', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('advalorem_line_mxn', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('value_usd', sa.Numeric(precision=29, scale=8), nullable=True), + sa.Column('value_commercial_usd', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('value_updated_usd', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('value_subitem_usd', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('sub_import_value_usd', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('value_returned_usd', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('value_depreciated_usd', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('customs_value_usd', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('value_auxiliary_usd', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('value_total_usd', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('value_temp_material_usd', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('value_def_material_usd', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('value_added_usd', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('value_national_packing_usd', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('value_us_packing_usd', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('vat_usd', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('vat_used_usd', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('value_non_originating_usd', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('value_originating_usd', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('igi_amount_usd', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('exempt_amount_usd', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('total_commercial_value', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('advalorem_line_usd', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('value_mc', sa.Numeric(precision=29, scale=8), nullable=True), + sa.Column('sub_import_value_mc', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('vat_mc', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('value_added_mc', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('value_national_packing_mc', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('value_total_mc', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('value_temp_material_mc', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('value_def_material_mc', sa.Numeric(precision=23, scale=8), nullable=True), + sa.ForeignKeyConstraint(['item_line_id'], ['a76.item_lines.id'], ), + sa.PrimaryKeyConstraint('id'), + schema='a76' + ) + op.create_table('item_line_quantities', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('item_line_id', sa.Integer(), nullable=False), + sa.Column('quantity', sa.Numeric(precision=19, scale=8), nullable=True), + sa.Column('alternate_quantity', sa.Numeric(precision=19, scale=8), nullable=True), + sa.Column('quantity_uma', sa.Numeric(precision=19, scale=8), nullable=True), + sa.Column('auxiliary_quantity', sa.Numeric(precision=19, scale=8), nullable=True), + sa.Column('quantity_temp_export', sa.Numeric(precision=19, scale=8), nullable=True), + sa.Column('serial_count', sa.Integer(), nullable=True), + sa.Column('net_weight', sa.Numeric(precision=19, scale=8), nullable=True), + sa.Column('gross_weight', sa.Numeric(precision=19, scale=8), nullable=True), + sa.Column('package_id', sa.Integer(), nullable=True), + sa.Column('package_quantity', sa.Integer(), nullable=True), + sa.Column('container_quantity', sa.SmallInteger(), nullable=True), + sa.Column('container_description', sa.String(length=40), nullable=True), + sa.Column('box_count', sa.String(length=30), nullable=True), + sa.ForeignKeyConstraint(['item_line_id'], ['a76.item_lines.id'], ), + sa.ForeignKeyConstraint(['package_id'], ['a76.packages.id'], ), + sa.PrimaryKeyConstraint('id'), + schema='a76' + ) + op.create_table('item_line_series', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('line_item_id', sa.Integer(), nullable=False), + sa.Column('row', sa.Integer(), nullable=False), + sa.Column('serial_numbers', sa.String(length=50), nullable=True), + sa.Column('model', sa.String(length=50), nullable=True), + sa.Column('sub_model', sa.String(length=50), nullable=True), + sa.Column('brand', sa.String(length=50), nullable=True), + sa.Column('number_id', sa.String(length=25), nullable=True), + sa.Column('discharge', sa.Boolean(), nullable=True), + sa.Column('serie_row', sa.Integer(), nullable=True), + sa.Column('image_path', sa.String(length=255), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['line_item_id'], ['a76.item_lines.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + schema='a76' + ) + op.create_index(op.f('ix_a76_item_line_series_company_id'), 'item_line_series', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_item_line_series_tenant_id'), 'item_line_series', ['tenant_id'], unique=False, schema='a76') + op.create_table('discharge_detail', + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('discharge_header_id', sa.BigInteger(), nullable=False), + sa.Column('export_item_line_id', sa.Integer(), nullable=True, comment='NULL for waste-only discharges.'), + sa.Column('part_number', sa.String(length=70), nullable=True, comment='NUMPARTE of the export line (denormalized)'), + sa.Column('export_part_number', sa.String(length=70), nullable=True, comment='NUMPARTEEXPO — as it appears in the pedimento'), + sa.Column('export_line_ref', sa.Integer(), nullable=True, comment='LINEAEXPOREF — for rectification references'), + sa.Column('import_item_line_id', sa.Integer(), nullable=False), + sa.Column('movement_id', sa.BigInteger(), nullable=False, comment='The BalanceMovement that records this consumption. Required.'), + sa.Column('quantity_discharged', sa.Numeric(precision=19, scale=8), nullable=False), + sa.Column('unit_of_measure', sa.String(length=5), nullable=True), + sa.Column('value_mn', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('value_me', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('net_weight', sa.Numeric(precision=19, scale=8), nullable=True), + sa.Column('gross_weight', sa.Numeric(precision=19, scale=8), nullable=True), + sa.Column('tariff_fraction', sa.String(length=10), nullable=True), + sa.Column('fraction_type', sa.String(length=7), nullable=True), + sa.Column('ad_valorem', sa.String(length=10), nullable=True), + sa.Column('country_of_origin', sa.String(length=3), nullable=True), + sa.Column('sector', sa.String(length=8), nullable=True), + sa.Column('original_part', sa.String(length=70), nullable=True, comment='PARTEORIGINAL'), + sa.Column('equivalent_quantity', sa.Numeric(precision=19, scale=8), nullable=True, comment='CANTEQUIVALENTE'), + sa.Column('equivalent_unit', sa.String(length=5), nullable=True), + sa.Column('returned_quantity_sm', sa.Numeric(precision=19, scale=8), nullable=True, comment='CANTRETORNADASAM'), + sa.Column('waste_type', sa.String(length=1), nullable=True, comment='M=merma, D=desperdicio, S=scrap'), + sa.Column('take_balance_base_pt', sa.String(length=2), nullable=True, comment='TOMARSALDOBASEALPT'), + sa.Column('igi_amount', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('tax_payment', sa.String(length=1), nullable=True), + sa.Column('has_certificate', sa.String(length=1), nullable=True), + sa.Column('iva_mn', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('iva_me', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('origin_import_invoice', sa.String(length=15), nullable=True, comment='FACTURAIMPO original (denorm for SM)'), + sa.Column('procedence', sa.String(length=3), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.CheckConstraint('movement_id IS NOT NULL', name='ck_dischdet_movement_required'), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['discharge_header_id'], ['a24.discharge_header.id'], ), + sa.ForeignKeyConstraint(['export_item_line_id'], ['a76.item_lines.id'], ), + sa.ForeignKeyConstraint(['import_item_line_id'], ['a76.item_lines.id'], ), + sa.ForeignKeyConstraint(['movement_id'], ['a24.balance_movement.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + schema='a24' + ) + op.create_index(op.f('ix_a24_discharge_detail_company_id'), 'discharge_detail', ['company_id'], unique=False, schema='a24') + op.create_index(op.f('ix_a24_discharge_detail_tenant_id'), 'discharge_detail', ['tenant_id'], unique=False, schema='a24') + op.create_index('ix_dischdet_export_line', 'discharge_detail', ['export_item_line_id'], unique=False, schema='a24') + op.create_index('ix_dischdet_header', 'discharge_detail', ['discharge_header_id'], unique=False, schema='a24') + op.create_index('ix_dischdet_import_lot', 'discharge_detail', ['import_item_line_id'], unique=False, schema='a24') + op.create_index('ix_dischdet_part', 'discharge_detail', ['tenant_id', 'part_number'], unique=False, schema='a24') + op.create_table('discharge_scrap', + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('discharge_header_id', sa.BigInteger(), nullable=True, comment='NULL when scrap is registered independently (not tied to an export).'), + sa.Column('import_item_line_id', sa.Integer(), nullable=False), + sa.Column('movement_id', sa.BigInteger(), nullable=True), + sa.Column('scrap_type', sa.String(length=1), nullable=False, comment='M=merma, D=desperdicio, S=scrap, X=destrucción'), + sa.Column('finished_good_line_id', sa.Integer(), nullable=True, comment='Export line of the product whose manufacture created this scrap.'), + sa.Column('finished_good_part', sa.String(length=70), nullable=True), + sa.Column('scrap_export_invoice_id', sa.BigInteger(), nullable=True, comment='If desperdicio has its own export pedimento.'), + sa.Column('part_number', sa.String(length=70), nullable=False), + sa.Column('item_class', sa.String(length=8), nullable=True), + sa.Column('quantity', sa.Numeric(precision=19, scale=8), nullable=False), + sa.Column('unit_of_measure', sa.String(length=5), nullable=False), + sa.Column('value_mn', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('value_me', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('net_weight', sa.Numeric(precision=19, scale=8), nullable=True), + sa.Column('procedence', sa.String(length=3), nullable=True), + sa.Column('scrap_date', sa.Date(), nullable=False), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['discharge_header_id'], ['a24.discharge_header.id'], ), + sa.ForeignKeyConstraint(['finished_good_line_id'], ['a76.item_lines.id'], ), + sa.ForeignKeyConstraint(['import_item_line_id'], ['a76.item_lines.id'], ), + sa.ForeignKeyConstraint(['movement_id'], ['a24.balance_movement.id'], ), + sa.ForeignKeyConstraint(['scrap_export_invoice_id'], ['a76.invoice_header.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + schema='a24' + ) + op.create_index(op.f('ix_a24_discharge_scrap_company_id'), 'discharge_scrap', ['company_id'], unique=False, schema='a24') + op.create_index(op.f('ix_a24_discharge_scrap_tenant_id'), 'discharge_scrap', ['tenant_id'], unique=False, schema='a24') + op.create_index('ix_dischscrap_date', 'discharge_scrap', ['tenant_id', 'scrap_date'], unique=False, schema='a24') + op.create_index('ix_dischscrap_header', 'discharge_scrap', ['discharge_header_id'], unique=False, schema='a24') + op.create_index('ix_dischscrap_import_lot', 'discharge_scrap', ['import_item_line_id'], unique=False, schema='a24') + op.create_table('inv_aphis_characteristic', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('aphis_general_id', sa.Integer(), nullable=False), + sa.Column('item_id', sa.String(length=50), nullable=True), + sa.Column('number_from', sa.String(length=50), nullable=True), + sa.Column('number_to', sa.String(length=50), nullable=True), + sa.Column('category_type', sa.String(length=50), nullable=True), + sa.Column('commodity_qua', sa.String(length=50), nullable=True), + sa.Column('commodity_char_qua', sa.String(length=50), nullable=True), + sa.Column('description', sa.String(length=200), nullable=True), + sa.Column('category_code', sa.String(length=50), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['aphis_general_id'], ['a24.inv_aphis_general.id'], ), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='inv_aphis_characteristic_pkey'), + schema='a24' + ) + op.create_index(op.f('ix_a24_inv_aphis_characteristic_company_id'), 'inv_aphis_characteristic', ['company_id'], unique=False, schema='a24') + op.create_index(op.f('ix_a24_inv_aphis_characteristic_tenant_id'), 'inv_aphis_characteristic', ['tenant_id'], unique=False, schema='a24') + op.create_table('inv_aphis_containers', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('aphis_general_id', sa.Integer(), nullable=False), + sa.Column('container_number', sa.String(length=50), nullable=True), + sa.Column('length', sa.String(length=20), nullable=True), + sa.Column('type', sa.String(length=50), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['aphis_general_id'], ['a24.inv_aphis_general.id'], ), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='inv_aphis_containers_pkey'), + schema='a24' + ) + op.create_index(op.f('ix_a24_inv_aphis_containers_company_id'), 'inv_aphis_containers', ['company_id'], unique=False, schema='a24') + op.create_index(op.f('ix_a24_inv_aphis_containers_tenant_id'), 'inv_aphis_containers', ['tenant_id'], unique=False, schema='a24') + op.create_table('inv_aphis_entities', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('aphis_general_id', sa.Integer(), nullable=False), + sa.Column('consignee_key', sa.String(length=50), nullable=True), + sa.Column('broker_key', sa.String(length=50), nullable=True), + sa.Column('lpco_auth_party_key', sa.String(length=50), nullable=True), + sa.Column('grower_key', sa.String(length=50), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['aphis_general_id'], ['a24.inv_aphis_general.id'], ), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='inv_aphis_entities_pkey'), + schema='a24' + ) + op.create_index(op.f('ix_a24_inv_aphis_entities_company_id'), 'inv_aphis_entities', ['company_id'], unique=False, schema='a24') + op.create_index(op.f('ix_a24_inv_aphis_entities_tenant_id'), 'inv_aphis_entities', ['tenant_id'], unique=False, schema='a24') + op.create_table('inv_aphis_lpcos', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('aphis_general_id', sa.Integer(), nullable=False), + sa.Column('issuer', sa.String(length=100), nullable=True), + sa.Column('issuer_loc_qua', sa.String(length=50), nullable=True), + sa.Column('issuer_loc', sa.String(length=50), nullable=True), + sa.Column('issuer_loc_desc', sa.String(length=200), nullable=True), + sa.Column('uom', sa.String(length=20), nullable=True), + sa.Column('txn_type', sa.String(length=50), nullable=True), + sa.Column('type', sa.String(length=50), nullable=True), + sa.Column('number', sa.String(length=50), nullable=True), + sa.Column('date_qual', sa.String(length=50), nullable=True), + sa.Column('date', sa.Date(), nullable=True), + sa.Column('qty', sa.String(length=50), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['aphis_general_id'], ['a24.inv_aphis_general.id'], ), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='inv_aphis_lpcos_pkey'), + schema='a24' + ) + op.create_index(op.f('ix_a24_inv_aphis_lpcos_company_id'), 'inv_aphis_lpcos', ['company_id'], unique=False, schema='a24') + op.create_index(op.f('ix_a24_inv_aphis_lpcos_tenant_id'), 'inv_aphis_lpcos', ['tenant_id'], unique=False, schema='a24') + op.create_table('inv_aphis_routing', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('aphis_general_id', sa.Integer(), nullable=False), + sa.Column('type', sa.String(length=50), nullable=True), + sa.Column('country', sa.String(length=50), nullable=True), + sa.Column('name', sa.String(length=100), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['aphis_general_id'], ['a24.inv_aphis_general.id'], ), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='inv_aphis_routing_pkey'), + schema='a24' + ) + op.create_index(op.f('ix_a24_inv_aphis_routing_company_id'), 'inv_aphis_routing', ['company_id'], unique=False, schema='a24') + op.create_index(op.f('ix_a24_inv_aphis_routing_tenant_id'), 'inv_aphis_routing', ['tenant_id'], unique=False, schema='a24') + op.create_table('inv_aphis_stype_pitems', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('aphis_general_id', sa.Integer(), nullable=False), + sa.Column('source_type_code', sa.String(length=50), nullable=True), + sa.Column('country_code', sa.String(length=3), nullable=True), + sa.Column('geo_location', sa.String(length=100), nullable=True), + sa.Column('processing_start', sa.Date(), nullable=True), + sa.Column('processing_end', sa.Date(), nullable=True), + sa.Column('processing_type', sa.String(length=50), nullable=True), + sa.Column('processing_desc', sa.String(length=200), nullable=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['aphis_general_id'], ['a24.inv_aphis_general.id'], ), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id', name='inv_aphis_stype_pitems_pkey'), + schema='a24' + ) + op.create_index(op.f('ix_a24_inv_aphis_stype_pitems_company_id'), 'inv_aphis_stype_pitems', ['company_id'], unique=False, schema='a24') + op.create_index(op.f('ix_a24_inv_aphis_stype_pitems_tenant_id'), 'inv_aphis_stype_pitems', ['tenant_id'], unique=False, schema='a24') + op.create_table('item_line_references', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('item_line_id', sa.Integer(), nullable=False), + sa.Column('serie_id', sa.Integer(), nullable=True), + sa.Column('customer_invoice', sa.Integer(), nullable=True), + sa.Column('assigned_client', sa.Integer(), nullable=True), + sa.Column('supplier', sa.Integer(), nullable=True), + sa.Column('requisitioner', sa.Integer(), nullable=True), + sa.Column('sent_to', sa.Integer(), nullable=True), + sa.Column('ped_line', sa.Integer(), nullable=True), + sa.Column('ro_line', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['assigned_client'], ['a76.clients_and_providers.id'], ), + sa.ForeignKeyConstraint(['customer_invoice'], ['a76.clients_and_providers.id'], ), + sa.ForeignKeyConstraint(['item_line_id'], ['a76.item_lines.id'], ), + sa.ForeignKeyConstraint(['requisitioner'], ['a76.clients_and_providers.id'], ), + sa.ForeignKeyConstraint(['sent_to'], ['a76.clients_and_providers.id'], ), + sa.ForeignKeyConstraint(['serie_id'], ['a76.item_line_series.id'], ), + sa.ForeignKeyConstraint(['supplier'], ['a76.clients_and_providers.id'], ), + sa.PrimaryKeyConstraint('id'), + schema='a76' + ) + + # Row-Level Security (multi-tenant). Ver estándar Aduanasoft — políticas usan GUCs app.tenant_id / app.company_id. + _enable_rls_tenant_company() + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + _disable_rls_tenant_company() + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('item_line_references', schema='a76') + op.drop_index(op.f('ix_a24_inv_aphis_stype_pitems_tenant_id'), table_name='inv_aphis_stype_pitems', schema='a24') + op.drop_index(op.f('ix_a24_inv_aphis_stype_pitems_company_id'), table_name='inv_aphis_stype_pitems', schema='a24') + op.drop_table('inv_aphis_stype_pitems', schema='a24') + op.drop_index(op.f('ix_a24_inv_aphis_routing_tenant_id'), table_name='inv_aphis_routing', schema='a24') + op.drop_index(op.f('ix_a24_inv_aphis_routing_company_id'), table_name='inv_aphis_routing', schema='a24') + op.drop_table('inv_aphis_routing', schema='a24') + op.drop_index(op.f('ix_a24_inv_aphis_lpcos_tenant_id'), table_name='inv_aphis_lpcos', schema='a24') + op.drop_index(op.f('ix_a24_inv_aphis_lpcos_company_id'), table_name='inv_aphis_lpcos', schema='a24') + op.drop_table('inv_aphis_lpcos', schema='a24') + op.drop_index(op.f('ix_a24_inv_aphis_entities_tenant_id'), table_name='inv_aphis_entities', schema='a24') + op.drop_index(op.f('ix_a24_inv_aphis_entities_company_id'), table_name='inv_aphis_entities', schema='a24') + op.drop_table('inv_aphis_entities', schema='a24') + op.drop_index(op.f('ix_a24_inv_aphis_containers_tenant_id'), table_name='inv_aphis_containers', schema='a24') + op.drop_index(op.f('ix_a24_inv_aphis_containers_company_id'), table_name='inv_aphis_containers', schema='a24') + op.drop_table('inv_aphis_containers', schema='a24') + op.drop_index(op.f('ix_a24_inv_aphis_characteristic_tenant_id'), table_name='inv_aphis_characteristic', schema='a24') + op.drop_index(op.f('ix_a24_inv_aphis_characteristic_company_id'), table_name='inv_aphis_characteristic', schema='a24') + op.drop_table('inv_aphis_characteristic', schema='a24') + op.drop_index('ix_dischscrap_import_lot', table_name='discharge_scrap', schema='a24') + op.drop_index('ix_dischscrap_header', table_name='discharge_scrap', schema='a24') + op.drop_index('ix_dischscrap_date', table_name='discharge_scrap', schema='a24') + op.drop_index(op.f('ix_a24_discharge_scrap_tenant_id'), table_name='discharge_scrap', schema='a24') + op.drop_index(op.f('ix_a24_discharge_scrap_company_id'), table_name='discharge_scrap', schema='a24') + op.drop_table('discharge_scrap', schema='a24') + op.drop_index('ix_dischdet_part', table_name='discharge_detail', schema='a24') + op.drop_index('ix_dischdet_import_lot', table_name='discharge_detail', schema='a24') + op.drop_index('ix_dischdet_header', table_name='discharge_detail', schema='a24') + op.drop_index('ix_dischdet_export_line', table_name='discharge_detail', schema='a24') + op.drop_index(op.f('ix_a24_discharge_detail_tenant_id'), table_name='discharge_detail', schema='a24') + op.drop_index(op.f('ix_a24_discharge_detail_company_id'), table_name='discharge_detail', schema='a24') + op.drop_table('discharge_detail', schema='a24') + op.drop_index(op.f('ix_a76_item_line_series_tenant_id'), table_name='item_line_series', schema='a76') + op.drop_index(op.f('ix_a76_item_line_series_company_id'), table_name='item_line_series', schema='a76') + op.drop_table('item_line_series', schema='a76') + op.drop_table('item_line_quantities', schema='a76') + op.drop_table('item_line_financials', schema='a76') + op.drop_table('item_line_descriptions', schema='a76') + op.drop_table('item_line_customs', schema='a76') + op.drop_index(op.f('ix_a76_identifier_details_tenant_id'), table_name='identifier_details', schema='a76') + op.drop_index(op.f('ix_a76_identifier_details_company_id'), table_name='identifier_details', schema='a76') + op.drop_table('identifier_details', schema='a76') + op.drop_index(op.f('ix_a76_ctm_receipts_tenant_id'), table_name='ctm_receipts', schema='a76') + op.drop_index(op.f('ix_a76_ctm_receipts_company_id'), table_name='ctm_receipts', schema='a76') + op.drop_table('ctm_receipts', schema='a76') + op.drop_index(op.f('ix_a24_inv_aphis_general_tenant_id'), table_name='inv_aphis_general', schema='a24') + op.drop_index(op.f('ix_a24_inv_aphis_general_company_id'), table_name='inv_aphis_general', schema='a24') + op.drop_table('inv_aphis_general', schema='a24') + op.drop_index(op.f('ix_a24_fa_item_lines_tenant_id'), table_name='fa_item_lines', schema='a24') + op.drop_index(op.f('ix_a24_fa_item_lines_company_id'), table_name='fa_item_lines', schema='a24') + op.drop_table('fa_item_lines', schema='a24') + op.drop_index('ix_balmov_source', table_name='balance_movement', schema='a24') + op.drop_index('ix_balmov_peps_lookup', table_name='balance_movement', schema='a24', postgresql_include=['import_item_line_id', 'quantity', 'value_me', 'value_mn']) + op.drop_index('ix_balmov_operation_date', table_name='balance_movement', schema='a24') + op.drop_index('ix_balmov_lot', table_name='balance_movement', schema='a24') + op.drop_index(op.f('ix_a24_balance_movement_tenant_id'), table_name='balance_movement', schema='a24') + op.drop_index(op.f('ix_a24_balance_movement_company_id'), table_name='balance_movement', schema='a24') + op.drop_table('balance_movement', schema='a24') + op.drop_index(op.f('ix_a76_item_lines_tenant_id'), table_name='item_lines', schema='a76') + op.drop_index(op.f('ix_a76_item_lines_company_id'), table_name='item_lines', schema='a76') + op.drop_table('item_lines', schema='a76') + op.drop_index(op.f('ix_a24_inv_partes_tenant_id'), table_name='inv_partes', schema='a24') + op.drop_index(op.f('ix_a24_inv_partes_company_id'), table_name='inv_partes', schema='a24') + op.drop_table('inv_partes', schema='a24') + op.drop_index(op.f('ix_a24_inv_parte_paises_tenant_id'), table_name='inv_parte_paises', schema='a24') + op.drop_index(op.f('ix_a24_inv_parte_paises_company_id'), table_name='inv_parte_paises', schema='a24') + op.drop_table('inv_parte_paises', schema='a24') + op.drop_index(op.f('ix_a24_inv_bom_tenant_id'), table_name='inv_bom', schema='a24') + op.drop_index(op.f('ix_a24_inv_bom_company_id'), table_name='inv_bom', schema='a24') + op.drop_table('inv_bom', schema='a24') + op.drop_index(op.f('ix_a24_fa_partes_tenant_id'), table_name='fa_partes', schema='a24') + op.drop_index(op.f('ix_a24_fa_partes_company_id'), table_name='fa_partes', schema='a24') + op.drop_table('fa_partes', schema='a24') + op.drop_index(op.f('ix_a76_pedimento_validation_tenant_id'), table_name='pedimento_validation', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_validation_company_id'), table_name='pedimento_validation', schema='a76') + op.drop_table('pedimento_validation', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_transport_means_tenant_id'), table_name='pedimento_transport_means', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_transport_means_company_id'), table_name='pedimento_transport_means', schema='a76') + op.drop_table('pedimento_transport_means', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_transport_carriers_tenant_id'), table_name='pedimento_transport_carriers', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_transport_carriers_company_id'), table_name='pedimento_transport_carriers', schema='a76') + op.drop_table('pedimento_transport_carriers', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_seals_tenant_id'), table_name='pedimento_seals', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_seals_company_id'), table_name='pedimento_seals', schema='a76') + op.drop_table('pedimento_seals', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_rectification_origin_tenant_id'), table_name='pedimento_rectification_origin', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_rectification_origin_company_id'), table_name='pedimento_rectification_origin', schema='a76') + op.drop_table('pedimento_rectification_origin', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_rectification_destination_tenant_id'), table_name='pedimento_rectification_destination', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_rectification_destination_company_id'), table_name='pedimento_rectification_destination', schema='a76') + op.drop_table('pedimento_rectification_destination', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_payments_tenant_id'), table_name='pedimento_payments', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_payments_company_id'), table_name='pedimento_payments', schema='a76') + op.drop_index('idx_pedimento_payments_pedimento_id', table_name='pedimento_payments', schema='a76') + op.drop_table('pedimento_payments', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_packages_tenant_id'), table_name='pedimento_packages', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_packages_company_id'), table_name='pedimento_packages', schema='a76') + op.drop_table('pedimento_packages', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_indexes_tenant_id'), table_name='pedimento_indexes', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_indexes_company_id'), table_name='pedimento_indexes', schema='a76') + op.drop_table('pedimento_indexes', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_incrementables_tenant_id'), table_name='pedimento_incrementables', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_incrementables_company_id'), table_name='pedimento_incrementables', schema='a76') + op.drop_table('pedimento_incrementables', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_guides_tenant_id'), table_name='pedimento_guides', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_guides_company_id'), table_name='pedimento_guides', schema='a76') + op.drop_table('pedimento_guides', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_decrementables_tenant_id'), table_name='pedimento_decrementables', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_decrementables_company_id'), table_name='pedimento_decrementables', schema='a76') + op.drop_table('pedimento_decrementables', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_dates_tenant_id'), table_name='pedimento_dates', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_dates_company_id'), table_name='pedimento_dates', schema='a76') + op.drop_index('idx_pedimento_dates_pedimento_id', table_name='pedimento_dates', schema='a76') + op.drop_table('pedimento_dates', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_customs_offices_tenant_id'), table_name='pedimento_customs_offices', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_customs_offices_company_id'), table_name='pedimento_customs_offices', schema='a76') + op.drop_table('pedimento_customs_offices', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_contributions_tenant_id'), table_name='pedimento_contributions', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_contributions_company_id'), table_name='pedimento_contributions', schema='a76') + op.drop_table('pedimento_contributions', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_containers_tenant_id'), table_name='pedimento_containers', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_containers_company_id'), table_name='pedimento_containers', schema='a76') + op.drop_table('pedimento_containers', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_config_updates_tenant_id'), table_name='pedimento_config_updates', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_config_updates_company_id'), table_name='pedimento_config_updates', schema='a76') + op.drop_table('pedimento_config_updates', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_config_update_rectification_tenant_id'), table_name='pedimento_config_update_rectification', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_config_update_rectification_company_id'), table_name='pedimento_config_update_rectification', schema='a76') + op.drop_table('pedimento_config_update_rectification', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_config_surcharges_tenant_id'), table_name='pedimento_config_surcharges', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_config_surcharges_company_id'), table_name='pedimento_config_surcharges', schema='a76') + op.drop_table('pedimento_config_surcharges', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_config_parameters_tenant_id'), table_name='pedimento_config_parameters', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_config_parameters_company_id'), table_name='pedimento_config_parameters', schema='a76') + op.drop_table('pedimento_config_parameters', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_config_calculations_tenant_id'), table_name='pedimento_config_calculations', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_config_calculations_company_id'), table_name='pedimento_config_calculations', schema='a76') + op.drop_table('pedimento_config_calculations', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_config_additional_tenant_id'), table_name='pedimento_config_additional', schema='a76') + op.drop_index(op.f('ix_a76_pedimento_config_additional_company_id'), table_name='pedimento_config_additional', schema='a76') + op.drop_table('pedimento_config_additional', schema='a76') + op.drop_index(op.f('ix_a76_parts_tenant_id'), table_name='parts', schema='a76') + op.drop_index(op.f('ix_a76_parts_company_id'), table_name='parts', schema='a76') + op.drop_table('parts', schema='a76') + op.drop_index(op.f('ix_a76_invoice_compliance_mx_tenant_id'), table_name='invoice_compliance_mx', schema='a76') + op.drop_index(op.f('ix_a76_invoice_compliance_mx_company_id'), table_name='invoice_compliance_mx', schema='a76') + op.drop_table('invoice_compliance_mx', schema='a76') + op.drop_index(op.f('ix_a76_doda_container_seals_tenant_id'), table_name='doda_container_seals', schema='a76') + op.drop_index(op.f('ix_a76_doda_container_seals_company_id'), table_name='doda_container_seals', schema='a76') + op.drop_table('doda_container_seals', schema='a76') + op.drop_index(op.f('ix_a24_inv_classes_tenant_id'), table_name='inv_classes', schema='a24') + op.drop_index(op.f('ix_a24_inv_classes_company_id'), table_name='inv_classes', schema='a24') + op.drop_table('inv_classes', schema='a24') + op.drop_index(op.f('ix_a24_fa_classes_tenant_id'), table_name='fa_classes', schema='a24') + op.drop_index(op.f('ix_a24_fa_classes_company_id'), table_name='fa_classes', schema='a24') + op.drop_table('fa_classes', schema='a24') + op.drop_index('ix_user_company_roles_user_company', table_name='user_company_roles', schema='core') + op.drop_index(op.f('ix_core_user_company_roles_user_id'), table_name='user_company_roles', schema='core') + op.drop_index(op.f('ix_core_user_company_roles_tenant_id'), table_name='user_company_roles', schema='core') + op.drop_index(op.f('ix_core_user_company_roles_id'), table_name='user_company_roles', schema='core') + op.drop_index(op.f('ix_core_user_company_roles_company_role_id'), table_name='user_company_roles', schema='core') + op.drop_index(op.f('ix_core_user_company_roles_company_id'), table_name='user_company_roles', schema='core') + op.drop_table('user_company_roles', schema='core') + op.drop_index('ix_role_permissions_composite', table_name='role_permissions', schema='core') + op.drop_index(op.f('ix_core_role_permissions_tenant_id'), table_name='role_permissions', schema='core') + op.drop_index(op.f('ix_core_role_permissions_permission_id'), table_name='role_permissions', schema='core') + op.drop_index(op.f('ix_core_role_permissions_id'), table_name='role_permissions', schema='core') + op.drop_index(op.f('ix_core_role_permissions_company_role_id'), table_name='role_permissions', schema='core') + op.drop_index(op.f('ix_core_role_permissions_company_id'), table_name='role_permissions', schema='core') + op.drop_table('role_permissions', schema='core') + op.drop_index(op.f('ix_a76_unit_conversions_tenant_id'), table_name='unit_conversions', schema='a76') + op.drop_index(op.f('ix_a76_unit_conversions_company_id'), table_name='unit_conversions', schema='a76') + op.drop_table('unit_conversions', schema='a76') + op.drop_index(op.f('ix_a76_pedimentos_tenant_id'), table_name='pedimentos', schema='a76') + op.drop_index(op.f('ix_a76_pedimentos_company_id'), table_name='pedimentos', schema='a76') + op.drop_index('idx_pedimentos_status', table_name='pedimentos', schema='a76') + op.drop_index('idx_pedimentos_created_at', table_name='pedimentos', schema='a76') + op.drop_index('idx_pedimentos_client_id', table_name='pedimentos', schema='a76') + op.drop_table('pedimentos', schema='a76') + op.drop_index(op.f('ix_a76_invoice_sales_details_tenant_id'), table_name='invoice_sales_details', schema='a76') + op.drop_index(op.f('ix_a76_invoice_sales_details_company_id'), table_name='invoice_sales_details', schema='a76') + op.drop_table('invoice_sales_details', schema='a76') + op.drop_index(op.f('ix_a76_invoice_logistics_tenant_id'), table_name='invoice_logistics', schema='a76') + op.drop_index(op.f('ix_a76_invoice_logistics_company_id'), table_name='invoice_logistics', schema='a76') + op.drop_table('invoice_logistics', schema='a76') + op.drop_index(op.f('ix_a76_invoice_financials_tenant_id'), table_name='invoice_financials', schema='a76') + op.drop_index(op.f('ix_a76_invoice_financials_company_id'), table_name='invoice_financials', schema='a76') + op.drop_table('invoice_financials', schema='a76') + op.drop_index(op.f('ix_a76_invoice_collections_tenant_id'), table_name='invoice_collections', schema='a76') + op.drop_index(op.f('ix_a76_invoice_collections_company_id'), table_name='invoice_collections', schema='a76') + op.drop_table('invoice_collections', schema='a76') + op.drop_index(op.f('ix_a76_fda_specifications_tenant_id'), table_name='fda_specifications', schema='a76') + op.drop_index(op.f('ix_a76_fda_specifications_fda_catalog_id'), table_name='fda_specifications', schema='a76') + op.drop_index(op.f('ix_a76_fda_specifications_company_id'), table_name='fda_specifications', schema='a76') + op.drop_table('fda_specifications', schema='a76') + op.drop_index(op.f('ix_a76_fda_lot_production_tenant_id'), table_name='fda_lot_production', schema='a76') + op.drop_index(op.f('ix_a76_fda_lot_production_fda_catalog_id'), table_name='fda_lot_production', schema='a76') + op.drop_index(op.f('ix_a76_fda_lot_production_company_id'), table_name='fda_lot_production', schema='a76') + op.drop_table('fda_lot_production', schema='a76') + op.drop_index(op.f('ix_a76_fda_constituent_elements_tenant_id'), table_name='fda_constituent_elements', schema='a76') + op.drop_index(op.f('ix_a76_fda_constituent_elements_fda_catalog_id'), table_name='fda_constituent_elements', schema='a76') + op.drop_index(op.f('ix_a76_fda_constituent_elements_company_id'), table_name='fda_constituent_elements', schema='a76') + op.drop_table('fda_constituent_elements', schema='a76') + op.drop_index(op.f('ix_a76_fda_affirmation_codes_tenant_id'), table_name='fda_affirmation_codes', schema='a76') + op.drop_index(op.f('ix_a76_fda_affirmation_codes_fda_catalog_id'), table_name='fda_affirmation_codes', schema='a76') + op.drop_index(op.f('ix_a76_fda_affirmation_codes_company_id'), table_name='fda_affirmation_codes', schema='a76') + op.drop_table('fda_affirmation_codes', schema='a76') + op.drop_index(op.f('ix_a76_fa_location_ext_tenant_id'), table_name='fa_location_ext', schema='a76') + op.drop_index(op.f('ix_a76_fa_location_ext_company_id'), table_name='fa_location_ext', schema='a76') + op.drop_table('fa_location_ext', schema='a76') + op.drop_index(op.f('ix_a76_error_catalogs_tenant_id'), table_name='error_catalogs', schema='a76') + op.drop_index(op.f('ix_a76_error_catalogs_company_id'), table_name='error_catalogs', schema='a76') + op.drop_table('error_catalogs', schema='a76') + op.drop_index(op.f('ix_a76_equivalencies_tenant_id'), table_name='equivalencies', schema='a76') + op.drop_index(op.f('ix_a76_equivalencies_company_id'), table_name='equivalencies', schema='a76') + op.drop_table('equivalencies', schema='a76') + op.drop_index(op.f('ix_a76_driver_tenant_id'), table_name='driver', schema='a76') + op.drop_index(op.f('ix_a76_driver_driver_id'), table_name='driver', schema='a76') + op.drop_index(op.f('ix_a76_driver_company_id'), table_name='driver', schema='a76') + op.drop_table('driver', schema='a76') + op.drop_index(op.f('ix_a76_doda_pedimentos_tenant_id'), table_name='doda_pedimentos', schema='a76') + op.drop_index(op.f('ix_a76_doda_pedimentos_company_id'), table_name='doda_pedimentos', schema='a76') + op.drop_table('doda_pedimentos', schema='a76') + op.drop_index(op.f('ix_a76_doda_containers_tenant_id'), table_name='doda_containers', schema='a76') + op.drop_index(op.f('ix_a76_doda_containers_company_id'), table_name='doda_containers', schema='a76') + op.drop_table('doda_containers', schema='a76') + op.drop_index(op.f('ix_a76_doda_american_pedimentos_tenant_id'), table_name='doda_american_pedimentos', schema='a76') + op.drop_index(op.f('ix_a76_doda_american_pedimentos_company_id'), table_name='doda_american_pedimentos', schema='a76') + op.drop_table('doda_american_pedimentos', schema='a76') + op.drop_index(op.f('ix_a76_customs_brokers_vu_tenant_id'), table_name='customs_brokers_vu', schema='a76') + op.drop_index(op.f('ix_a76_customs_brokers_vu_company_id'), table_name='customs_brokers_vu', schema='a76') + op.drop_table('customs_brokers_vu', schema='a76') + op.drop_index(op.f('ix_a76_customs_brokers_personnel_tenant_id'), table_name='customs_brokers_personnel', schema='a76') + op.drop_index(op.f('ix_a76_customs_brokers_personnel_company_id'), table_name='customs_brokers_personnel', schema='a76') + op.drop_table('customs_brokers_personnel', schema='a76') + op.drop_index(op.f('ix_a76_country_rule_oct_tenant_id'), table_name='country_rule_oct', schema='a76') + op.drop_index(op.f('ix_a76_country_rule_oct_company_id'), table_name='country_rule_oct', schema='a76') + op.drop_table('country_rule_oct', schema='a76') + op.drop_index(op.f('ix_a76_concepts_tenant_id'), table_name='concepts', schema='a76') + op.drop_table('concepts', schema='a76') + op.drop_index(op.f('ix_a76_concept_manifestations_tenant_id'), table_name='concept_manifestations', schema='a76') + op.drop_index(op.f('ix_a76_concept_manifestations_company_id'), table_name='concept_manifestations', schema='a76') + op.drop_index('idx_concept_manifestations_value_manifestation_id', table_name='concept_manifestations', schema='a76') + op.drop_table('concept_manifestations', schema='a76') + op.drop_index(op.f('ix_a76_clients_and_providers_programs_tenant_id'), table_name='clients_and_providers_programs', schema='a76') + op.drop_index(op.f('ix_a76_clients_and_providers_programs_company_id'), table_name='clients_and_providers_programs', schema='a76') + op.drop_table('clients_and_providers_programs', schema='a76') + op.drop_index(op.f('ix_a76_clients_and_providers_address_tenant_id'), table_name='clients_and_providers_address', schema='a76') + op.drop_index(op.f('ix_a76_clients_and_providers_address_company_id'), table_name='clients_and_providers_address', schema='a76') + op.drop_table('clients_and_providers_address', schema='a76') + op.drop_index(op.f('ix_a76_classes_tenant_id'), table_name='classes', schema='a76') + op.drop_index(op.f('ix_a76_classes_company_id'), table_name='classes', schema='a76') + op.drop_table('classes', schema='a76') + op.drop_index('ix_dischdr_source', table_name='discharge_header', schema='a24') + op.drop_index('ix_dischdr_date', table_name='discharge_header', schema='a24') + op.drop_index(op.f('ix_a24_discharge_header_tenant_id'), table_name='discharge_header', schema='a24') + op.drop_index(op.f('ix_a24_discharge_header_company_id'), table_name='discharge_header', schema='a24') + op.drop_table('discharge_header', schema='a24') + op.drop_index(op.f('ix_public_warning_fractions_warning_type'), table_name='warning_fractions', schema='public') + op.drop_index(op.f('ix_public_warning_fractions_tenant_id'), table_name='warning_fractions', schema='public') + op.drop_index(op.f('ix_public_warning_fractions_fraction'), table_name='warning_fractions', schema='public') + op.drop_index(op.f('ix_public_warning_fractions_company_id'), table_name='warning_fractions', schema='public') + op.drop_table('warning_fractions', schema='public') + op.drop_index(op.f('ix_core_user_tenants_tenant_id'), table_name='user_tenants', schema='core') + op.drop_index(op.f('ix_core_user_tenants_keycloak_user_id'), table_name='user_tenants', schema='core') + op.drop_index(op.f('ix_core_user_tenants_id'), table_name='user_tenants', schema='core') + op.drop_index(op.f('ix_core_user_tenants_company_id'), table_name='user_tenants', schema='core') + op.drop_table('user_tenants', schema='core') + op.drop_index('ix_user_company_permissions_composite', table_name='user_company_permissions', schema='core') + op.drop_index(op.f('ix_core_user_company_permissions_user_id'), table_name='user_company_permissions', schema='core') + op.drop_index(op.f('ix_core_user_company_permissions_tenant_id'), table_name='user_company_permissions', schema='core') + op.drop_index(op.f('ix_core_user_company_permissions_permission_id'), table_name='user_company_permissions', schema='core') + op.drop_index(op.f('ix_core_user_company_permissions_id'), table_name='user_company_permissions', schema='core') + op.drop_index(op.f('ix_core_user_company_permissions_company_id'), table_name='user_company_permissions', schema='core') + op.drop_table('user_company_permissions', schema='core') + op.drop_index('ix_core_task_runs_tenant_updated', table_name='task_runs', schema='core') + op.drop_index('ix_core_task_runs_tenant_status_updated', table_name='task_runs', schema='core') + op.drop_index(op.f('ix_core_task_runs_tenant_id'), table_name='task_runs', schema='core') + op.drop_index('ix_core_task_runs_tenant_group_updated', table_name='task_runs', schema='core') + op.drop_index('ix_core_task_runs_tenant_company_updated', table_name='task_runs', schema='core') + op.drop_index('ix_core_task_runs_task_id', table_name='task_runs', schema='core') + op.drop_index(op.f('ix_core_task_runs_company_id'), table_name='task_runs', schema='core') + op.drop_table('task_runs', schema='core') + op.drop_index(op.f('ix_core_company_roles_tenant_id'), table_name='company_roles', schema='core') + op.drop_index(op.f('ix_core_company_roles_id'), table_name='company_roles', schema='core') + op.drop_index(op.f('ix_core_company_roles_company_id'), table_name='company_roles', schema='core') + op.drop_index('ix_company_roles_company_id_is_active', table_name='company_roles', schema='core') + op.drop_table('company_roles', schema='core') + op.drop_index(op.f('ix_a76_vehicle_vehicle_id'), table_name='vehicle', schema='a76') + op.drop_index(op.f('ix_a76_vehicle_tenant_id'), table_name='vehicle', schema='a76') + op.drop_index(op.f('ix_a76_vehicle_company_id'), table_name='vehicle', schema='a76') + op.drop_table('vehicle', schema='a76') + op.drop_index(op.f('ix_a76_value_manifestations_tenant_id'), table_name='value_manifestations', schema='a76') + op.drop_index(op.f('ix_a76_value_manifestations_company_id'), table_name='value_manifestations', schema='a76') + op.drop_index('idx_value_manifestations_manifestation_number', table_name='value_manifestations', schema='a76') + op.drop_table('value_manifestations', schema='a76') + op.drop_index(op.f('ix_a76_us_tariff_fractions_tenant_id'), table_name='us_tariff_fractions', schema='a76') + op.drop_index(op.f('ix_a76_us_tariff_fractions_id'), table_name='us_tariff_fractions', schema='a76') + op.drop_index(op.f('ix_a76_us_tariff_fractions_company_id'), table_name='us_tariff_fractions', schema='a76') + op.drop_table('us_tariff_fractions', schema='a76') + op.drop_index(op.f('ix_a76_units_of_measure_general_tenant_id'), table_name='units_of_measure_general', schema='a76') + op.drop_index(op.f('ix_a76_units_of_measure_general_company_id'), table_name='units_of_measure_general', schema='a76') + op.drop_table('units_of_measure_general', schema='a76') + op.drop_index(op.f('ix_a76_units_of_measure_tenant_id'), table_name='units_of_measure', schema='a76') + op.drop_index(op.f('ix_a76_units_of_measure_company_id'), table_name='units_of_measure', schema='a76') + op.drop_table('units_of_measure', schema='a76') + op.drop_index(op.f('ix_a76_transporter_transporter_id'), table_name='transporter', schema='a76') + op.drop_index(op.f('ix_a76_transporter_tenant_id'), table_name='transporter', schema='a76') + op.drop_index(op.f('ix_a76_transporter_company_id'), table_name='transporter', schema='a76') + op.drop_table('transporter', schema='a76') + op.drop_index(op.f('ix_a76_trailer_trailer_id'), table_name='trailer', schema='a76') + op.drop_index(op.f('ix_a76_trailer_tenant_id'), table_name='trailer', schema='a76') + op.drop_index(op.f('ix_a76_trailer_company_id'), table_name='trailer', schema='a76') + op.drop_table('trailer', schema='a76') + op.drop_index(op.f('ix_a76_subassembly_entries_tenant_id'), table_name='subassembly_entries', schema='a76') + op.drop_index(op.f('ix_a76_subassembly_entries_company_id'), table_name='subassembly_entries', schema='a76') + op.drop_table('subassembly_entries', schema='a76') + op.drop_index(op.f('ix_a76_signatures_tenant_id'), table_name='signatures', schema='a76') + op.drop_index(op.f('ix_a76_signatures_company_id'), table_name='signatures', schema='a76') + op.drop_table('signatures', schema='a76') + op.drop_index(op.f('ix_a76_sectors_tenant_id'), table_name='sectors', schema='a76') + op.drop_index(op.f('ix_a76_sectors_company_id'), table_name='sectors', schema='a76') + op.drop_table('sectors', schema='a76') + op.drop_index(op.f('ix_a76_seal_tenant_id'), table_name='seal', schema='a76') + op.drop_index(op.f('ix_a76_seal_company_id'), table_name='seal', schema='a76') + op.drop_table('seal', schema='a76') + op.drop_index(op.f('ix_a76_previous_fractions_tenant_id'), table_name='previous_fractions', schema='a76') + op.drop_index(op.f('ix_a76_previous_fractions_company_id'), table_name='previous_fractions', schema='a76') + op.drop_table('previous_fractions', schema='a76') + op.drop_index(op.f('ix_a76_prevalidators_tenant_id'), table_name='prevalidators', schema='a76') + op.drop_index(op.f('ix_a76_prevalidators_company_id'), table_name='prevalidators', schema='a76') + op.drop_table('prevalidators', schema='a76') + op.drop_index(op.f('ix_a76_ports_tenant_id'), table_name='ports', schema='a76') + op.drop_index(op.f('ix_a76_ports_company_id'), table_name='ports', schema='a76') + op.drop_table('ports', schema='a76') + op.drop_index(op.f('ix_a76_permission_rule_octave_tenant_id'), table_name='permission_rule_octave', schema='a76') + op.drop_index(op.f('ix_a76_permission_rule_octave_company_id'), table_name='permission_rule_octave', schema='a76') + op.drop_table('permission_rule_octave', schema='a76') + op.drop_index(op.f('ix_a76_permission_rule_oct_tenant_id'), table_name='permission_rule_oct', schema='a76') + op.drop_index(op.f('ix_a76_permission_rule_oct_company_id'), table_name='permission_rule_oct', schema='a76') + op.drop_table('permission_rule_oct', schema='a76') + op.drop_index(op.f('ix_a76_packing_lists_tenant_id'), table_name='packing_lists', schema='a76') + op.drop_index(op.f('ix_a76_packing_lists_company_id'), table_name='packing_lists', schema='a76') + op.drop_table('packing_lists', schema='a76') + op.drop_index(op.f('ix_a76_packages_tenant_id'), table_name='packages', schema='a76') + op.drop_index(op.f('ix_a76_packages_company_id'), table_name='packages', schema='a76') + op.drop_table('packages', schema='a76') + op.drop_index(op.f('ix_a76_octave_balance_tenant_id'), table_name='octave_balance', schema='a76') + op.drop_index(op.f('ix_a76_octave_balance_company_id'), table_name='octave_balance', schema='a76') + op.drop_table('octave_balance', schema='a76') + op.drop_index(op.f('ix_a76_multi_currency_types_tenant_id'), table_name='multi_currency_types', schema='a76') + op.drop_index(op.f('ix_a76_multi_currency_types_company_id'), table_name='multi_currency_types', schema='a76') + op.drop_table('multi_currency_types', schema='a76') + op.drop_index(op.f('ix_a76_manifests_tenant_id'), table_name='manifests', schema='a76') + op.drop_index(op.f('ix_a76_manifests_company_id'), table_name='manifests', schema='a76') + op.drop_index('idx_manifests_manifest_number', table_name='manifests', schema='a76') + op.drop_table('manifests', schema='a76') + op.drop_index(op.f('ix_a76_manifest_drivers_tenant_id'), table_name='manifest_drivers', schema='a76') + op.drop_index(op.f('ix_a76_manifest_drivers_company_id'), table_name='manifest_drivers', schema='a76') + op.drop_index('idx_manifest_drivers_manifest_number', table_name='manifest_drivers', schema='a76') + op.drop_table('manifest_drivers', schema='a76') + op.drop_index(op.f('ix_a76_manifest_anexos_tenant_id'), table_name='manifest_anexos', schema='a76') + op.drop_index(op.f('ix_a76_manifest_anexos_company_id'), table_name='manifest_anexos', schema='a76') + op.drop_index('idx_manifest_anexos_consecutive', table_name='manifest_anexos', schema='a76') + op.drop_table('manifest_anexos', schema='a76') + op.drop_index(op.f('ix_a76_location_tenant_id'), table_name='location', schema='a76') + op.drop_index(op.f('ix_a76_location_company_id'), table_name='location', schema='a76') + op.drop_table('location', schema='a76') + op.drop_index(op.f('ix_a76_legends_tenant_id'), table_name='legends', schema='a76') + op.drop_index(op.f('ix_a76_legends_company_id'), table_name='legends', schema='a76') + op.drop_table('legends', schema='a76') + op.drop_index(op.f('ix_a76_item_presets_tenant_id'), table_name='item_presets', schema='a76') + op.drop_index(op.f('ix_a76_item_presets_company_id'), table_name='item_presets', schema='a76') + op.drop_table('item_presets', schema='a76') + op.drop_index(op.f('ix_a76_invoice_settings_tenant_id'), table_name='invoice_settings', schema='a76') + op.drop_index(op.f('ix_a76_invoice_settings_company_id'), table_name='invoice_settings', schema='a76') + op.drop_table('invoice_settings', schema='a76') + op.drop_index(op.f('ix_a76_invoice_header_tenant_id'), table_name='invoice_header', schema='a76') + op.drop_index(op.f('ix_a76_invoice_header_company_id'), table_name='invoice_header', schema='a76') + op.drop_table('invoice_header', schema='a76') + op.drop_index(op.f('ix_a76_inpc_tenant_id'), table_name='inpc', schema='a76') + op.drop_index(op.f('ix_a76_inpc_company_id'), table_name='inpc', schema='a76') + op.drop_table('inpc', schema='a76') + op.drop_index(op.f('ix_a76_identifiers_tenant_id'), table_name='identifiers', schema='a76') + op.drop_index(op.f('ix_a76_identifiers_company_id'), table_name='identifiers', schema='a76') + op.drop_table('identifiers', schema='a76') + op.drop_index(op.f('ix_a76_historical_tariff_fractions_tenant_id'), table_name='historical_tariff_fractions', schema='a76') + op.drop_index(op.f('ix_a76_historical_tariff_fractions_company_id'), table_name='historical_tariff_fractions', schema='a76') + op.drop_table('historical_tariff_fractions', schema='a76') + op.drop_index(op.f('ix_a76_fraction_rule_octave_tenant_id'), table_name='fraction_rule_octave', schema='a76') + op.drop_index(op.f('ix_a76_fraction_rule_octave_company_id'), table_name='fraction_rule_octave', schema='a76') + op.drop_table('fraction_rule_octave', schema='a76') + op.drop_index(op.f('ix_a76_fda_catalog_tenant_id'), table_name='fda_catalog', schema='a76') + op.drop_index(op.f('ix_a76_fda_catalog_fda_key'), table_name='fda_catalog', schema='a76') + op.drop_index(op.f('ix_a76_fda_catalog_description'), table_name='fda_catalog', schema='a76') + op.drop_index(op.f('ix_a76_fda_catalog_company_id'), table_name='fda_catalog', schema='a76') + op.drop_table('fda_catalog', schema='a76') + op.drop_index(op.f('ix_a76_expediente_archivo_tenant_id'), table_name='expediente_archivo', schema='a76') + op.drop_index(op.f('ix_a76_expediente_archivo_task_id'), table_name='expediente_archivo', schema='a76') + op.drop_index(op.f('ix_a76_expediente_archivo_external_task_id'), table_name='expediente_archivo', schema='a76') + op.drop_index(op.f('ix_a76_expediente_archivo_company_id'), table_name='expediente_archivo', schema='a76') + op.drop_table('expediente_archivo', schema='a76') + op.drop_index(op.f('ix_a76_exchange_rate_tenant_id'), table_name='exchange_rate', schema='a76') + op.drop_index(op.f('ix_a76_exchange_rate_company_id'), table_name='exchange_rate', schema='a76') + op.drop_table('exchange_rate', schema='a76') + op.drop_index(op.f('ix_a76_error_classifications_tenant_id'), table_name='error_classifications', schema='a76') + op.drop_index(op.f('ix_a76_error_classifications_company_id'), table_name='error_classifications', schema='a76') + op.drop_table('error_classifications', schema='a76') + op.drop_index(op.f('ix_a76_equivalency_items_tenant_id'), table_name='equivalency_items', schema='a76') + op.drop_index(op.f('ix_a76_equivalency_items_company_id'), table_name='equivalency_items', schema='a76') + op.drop_table('equivalency_items', schema='a76') + op.drop_index(op.f('ix_a76_electronic_notices_tenant_id'), table_name='electronic_notices', schema='a76') + op.drop_index(op.f('ix_a76_electronic_notices_company_id'), table_name='electronic_notices', schema='a76') + op.drop_table('electronic_notices', schema='a76') + op.drop_index(op.f("ix_a76_doda_alta_log_task_id"), table_name="doda_alta_log", schema="a76") + op.drop_index(op.f("ix_a76_doda_alta_log_doda_id"), table_name="doda_alta_log", schema="a76") + op.drop_index(op.f("ix_a76_doda_alta_log_tenant_id"), table_name="doda_alta_log", schema="a76") + op.drop_index(op.f("ix_a76_doda_alta_log_company_id"), table_name="doda_alta_log", schema="a76") + op.drop_table("doda_alta_log", schema="a76") + op.drop_index(op.f('ix_a76_doda_tenant_id'), table_name='doda', schema='a76') + op.drop_index(op.f('ix_a76_doda_company_id'), table_name='doda', schema='a76') + op.drop_table('doda', schema='a76') + op.drop_index(op.f('ix_a76_document_types_digitization_tenant_id'), table_name='document_types_digitization', schema='a76') + op.drop_index(op.f('ix_a76_document_types_digitization_company_id'), table_name='document_types_digitization', schema='a76') + op.drop_index(op.f('ix_a76_document_types_digitization_code'), table_name='document_types_digitization', schema='a76') + op.drop_table('document_types_digitization', schema='a76') + op.drop_index(op.f('ix_a76_depreciation_catalog_tenant_id'), table_name='depreciation_catalog', schema='a76') + op.drop_index(op.f('ix_a76_depreciation_catalog_fraction'), table_name='depreciation_catalog', schema='a76') + op.drop_index(op.f('ix_a76_depreciation_catalog_description'), table_name='depreciation_catalog', schema='a76') + op.drop_index(op.f('ix_a76_depreciation_catalog_company_id'), table_name='depreciation_catalog', schema='a76') + op.drop_table('depreciation_catalog', schema='a76') + op.drop_index(op.f('ix_a76_customs_brokers_tenant_id'), table_name='customs_brokers', schema='a76') + op.drop_index(op.f('ix_a76_customs_brokers_company_id'), table_name='customs_brokers', schema='a76') + op.drop_table('customs_brokers', schema='a76') + op.drop_index(op.f('ix_a76_company_prevalidator_company_id'), table_name='company_prevalidator', schema='a76') + op.drop_table('company_prevalidator', schema='a76') + op.drop_index(op.f('ix_a76_company_electronic_agent_company_id'), table_name='company_electronic_agent', schema='a76') + op.drop_table('company_electronic_agent', schema='a76') + op.drop_index(op.f('ix_a76_company_digital_certificate_company_id'), table_name='company_digital_certificate', schema='a76') + op.drop_table('company_digital_certificate', schema='a76') + op.drop_index(op.f('ix_a76_company_cfdi_company_id'), table_name='company_cfdi', schema='a76') + op.drop_table('company_cfdi', schema='a76') + op.drop_index(op.f('ix_a76_company_certification_company_id'), table_name='company_certification', schema='a76') + op.drop_table('company_certification', schema='a76') + op.drop_index(op.f('ix_a76_company_address_company_id'), table_name='company_address', schema='a76') + op.drop_table('company_address', schema='a76') + op.drop_index(op.f('ix_a76_clients_and_providers_tenant_id'), table_name='clients_and_providers', schema='a76') + op.drop_index(op.f('ix_a76_clients_and_providers_company_id'), table_name='clients_and_providers', schema='a76') + op.drop_table('clients_and_providers', schema='a76') + op.drop_index(op.f('ix_a76_classification_concepts_tenant_id'), table_name='classification_concepts', schema='a76') + op.drop_index(op.f('ix_a76_classification_concepts_company_id'), table_name='classification_concepts', schema='a76') + op.drop_table('classification_concepts', schema='a76') + op.drop_index(op.f('ix_a76_canadian_tariff_fractions_tenant_id'), table_name='canadian_tariff_fractions', schema='a76') + op.drop_index(op.f('ix_a76_canadian_tariff_fractions_id'), table_name='canadian_tariff_fractions', schema='a76') + op.drop_index(op.f('ix_a76_canadian_tariff_fractions_fraction'), table_name='canadian_tariff_fractions', schema='a76') + op.drop_index(op.f('ix_a76_canadian_tariff_fractions_country_code'), table_name='canadian_tariff_fractions', schema='a76') + op.drop_index(op.f('ix_a76_canadian_tariff_fractions_company_id'), table_name='canadian_tariff_fractions', schema='a76') + op.drop_table('canadian_tariff_fractions', schema='a76') + op.drop_index(op.f('ix_a76_audit_logs_username'), table_name='audit_logs', schema='a76') + op.drop_index(op.f('ix_a76_audit_logs_timestamp'), table_name='audit_logs', schema='a76') + op.drop_index(op.f('ix_a76_audit_logs_tenant_id'), table_name='audit_logs', schema='a76') + op.drop_index(op.f('ix_a76_audit_logs_table_name'), table_name='audit_logs', schema='a76') + op.drop_index(op.f('ix_a76_audit_logs_system'), table_name='audit_logs', schema='a76') + op.drop_index(op.f('ix_a76_audit_logs_session_id'), table_name='audit_logs', schema='a76') + op.drop_index(op.f('ix_a76_audit_logs_reference'), table_name='audit_logs', schema='a76') + op.drop_index(op.f('ix_a76_audit_logs_record_id'), table_name='audit_logs', schema='a76') + op.drop_index(op.f('ix_a76_audit_logs_procedure'), table_name='audit_logs', schema='a76') + op.drop_index(op.f('ix_a76_audit_logs_operation_type'), table_name='audit_logs', schema='a76') + op.drop_index(op.f('ix_a76_audit_logs_date'), table_name='audit_logs', schema='a76') + op.drop_index(op.f('ix_a76_audit_logs_company_id'), table_name='audit_logs', schema='a76') + op.drop_index('idx_audit_username_date', table_name='audit_logs', schema='a76') + op.drop_index('idx_audit_table_record', table_name='audit_logs', schema='a76') + op.drop_index('idx_audit_system_timestamp', table_name='audit_logs', schema='a76') + op.drop_index('idx_audit_procedure_date', table_name='audit_logs', schema='a76') + op.drop_table('audit_logs', schema='a76') + op.drop_index(op.f('ix_a76_app_settings_tenant_id'), table_name='app_settings', schema='a76') + op.drop_index(op.f('ix_a76_app_settings_company_id'), table_name='app_settings', schema='a76') + op.drop_table('app_settings', schema='a76') + op.drop_index(op.f('ix_a76_CompanyVU_company_id'), table_name='CompanyVU', schema='a76') + op.drop_table('CompanyVU', schema='a76') + op.drop_table('states', schema='public') + op.drop_table('code_pedimento_regimens', schema='public') + op.drop_index(op.f('ix_core_licenses_tenant_id'), table_name='licenses', schema='core') + op.drop_index(op.f('ix_core_licenses_id'), table_name='licenses', schema='core') + op.drop_table('licenses', schema='core') + op.drop_index(op.f('ix_core_license_usage_tenant_id'), table_name='license_usage', schema='core') + op.drop_index(op.f('ix_core_license_usage_id'), table_name='license_usage', schema='core') + op.drop_table('license_usage', schema='core') + op.drop_index(op.f('ix_a76_customs_broker_concepts_tenant_id'), table_name='customs_broker_concepts', schema='a76') + op.drop_table('customs_broker_concepts', schema='a76') + op.drop_index(op.f('ix_a76_company_tenant_id'), table_name='company', schema='a76') + op.drop_table('company', schema='a76') + op.drop_table('valuation_methods', schema='public') + op.drop_table('transport_types', schema='public') + op.drop_table('transport_modes', schema='public') + op.drop_table('trailer_type', schema='public') + op.drop_table('pedimento_transport_catalog', schema='public') + op.drop_table('pedimento_regimens', schema='public') + op.drop_table('pedimento_codes', schema='public') + op.drop_table('payment_methods', schema='public') + op.drop_table('material_types', schema='public') + op.drop_table('license_exceptions', schema='public') + op.drop_table('invoice_types', schema='public') + op.drop_table('incoterms', schema='public') + op.drop_table('identifiers', schema='public') + op.drop_table('customs_warehouses', schema='public') + op.drop_table('customs_sections', schema='public') + op.drop_table('currency_types', schema='public') + op.drop_index('ak_country_ame', table_name='countries', schema='public') + op.drop_table('countries', schema='public') + op.drop_index(op.f('ix_public_carta_porte_codes_code'), table_name='carta_porte_codes', schema='public') + op.drop_table('carta_porte_codes', schema='public') + op.drop_index(op.f('ix_public_agency_tariff_codes_tariff_flag_code'), table_name='agency_tariff_codes', schema='public') + op.drop_index(op.f('ix_public_agency_tariff_codes_program_code'), table_name='agency_tariff_codes', schema='public') + op.drop_index(op.f('ix_public_agency_tariff_codes_agency_code'), table_name='agency_tariff_codes', schema='public') + op.drop_table('agency_tariff_codes', schema='public') + op.drop_index(op.f('ix_help_articles_uuid'), table_name='help_articles') + op.drop_index(op.f('ix_help_articles_slug'), table_name='help_articles') + op.drop_table('help_articles') + op.drop_index(op.f('ix_core_tenants_slug'), table_name='tenants', schema='core') + op.drop_index(op.f('ix_core_tenants_name'), table_name='tenants', schema='core') + op.drop_index(op.f('ix_core_tenants_id'), table_name='tenants', schema='core') + op.drop_table('tenants', schema='core') + op.drop_index(op.f('ix_core_permissions_module'), table_name='permissions', schema='core') + op.drop_index(op.f('ix_core_permissions_id'), table_name='permissions', schema='core') + op.drop_index(op.f('ix_core_permissions_code'), table_name='permissions', schema='core') + op.drop_table('permissions', schema='core') + op.drop_table('containers') + op.drop_table('unit_of_measure_oma', schema='a76') + op.drop_table('unit_of_measure_customs', schema='a76') + op.drop_table('unit_of_measure_american', schema='a76') + op.drop_table('unit_of_measure_ace', schema='a76') + op.drop_index(op.f('ix_a76_tariff_fractions_fraction'), table_name='tariff_fractions', schema='a76') + op.drop_index(op.f('ix_a76_tariff_fractions_code'), table_name='tariff_fractions', schema='a76') + op.drop_table('tariff_fractions', schema='a76') + op.drop_index(op.f('ix_a24_inv_aphis_catalog_company_id'), table_name='inv_aphis_catalog', schema='a24') + op.drop_table('inv_aphis_catalog', schema='a24') + # ### end Alembic commands ### + + # Eliminar ENUM types de PostgreSQL — no se borran automáticamente con las tablas + op.execute("DROP TYPE IF EXISTS tenanttype") + op.execute("DROP TYPE IF EXISTS licenseplan") + op.execute("DROP TYPE IF EXISTS licensestatus") + op.execute("DROP TYPE IF EXISTS entity_client_or_provider") diff --git a/backend/alembic/versions/a1b2c3d4e5f6_add_name_fields_to_user_tenants.py b/backend/alembic/versions/a1b2c3d4e5f6_add_name_fields_to_user_tenants.py new file mode 100644 index 0000000..fbf471a --- /dev/null +++ b/backend/alembic/versions/a1b2c3d4e5f6_add_name_fields_to_user_tenants.py @@ -0,0 +1,35 @@ +"""add first_name and last_name to user_tenants + +Revision ID: a1b2c3d4e5f6 +Revises: 8c9bad3da37f +Create Date: 2026-05-06 00:00:00.000000 + +""" +from typing import Sequence, Union +import sqlalchemy as sa +from alembic import op + +revision: str = "a1b2c3d4e5f6" +down_revision: Union[str, None] = "8c9bad3da37f" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "user_tenants", + sa.Column("first_name", sa.String(100), nullable=True, + comment="Nombre (caché local de Keycloak)"), + schema="core", + ) + op.add_column( + "user_tenants", + sa.Column("last_name", sa.String(100), nullable=True, + comment="Apellido (caché local de Keycloak)"), + schema="core", + ) + + +def downgrade() -> None: + op.drop_column("user_tenants", "last_name", schema="core") + op.drop_column("user_tenants", "first_name", schema="core") diff --git a/backend/alembic/versions/b2c3d4e5f6a7_add_invite_tokens.py b/backend/alembic/versions/b2c3d4e5f6a7_add_invite_tokens.py new file mode 100644 index 0000000..a31f128 --- /dev/null +++ b/backend/alembic/versions/b2c3d4e5f6a7_add_invite_tokens.py @@ -0,0 +1,78 @@ +"""add invite_tokens table + +Revision ID: b2c3d4e5f6a7 +Revises: a1b2c3d4e5f6 +Create Date: 2026-05-06 12:00:00.000000 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "b2c3d4e5f6a7" +down_revision: Union[str, None] = "a1b2c3d4e5f6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "invite_tokens", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("token_hash", sa.String(64), nullable=False, unique=True), + sa.Column("tenant_slug", sa.String(100), nullable=False), + sa.Column("email", sa.String(255), nullable=False), + sa.Column("role", sa.String(50), nullable=False, server_default="user"), + sa.Column("created_by", sa.String(255), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("used_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("product_ids", sa.JSON(), nullable=True), + sa.Column("company_id", sa.Integer(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + schema="core", + ) + op.create_index( + "ix_core_invite_tokens_token_hash", + "invite_tokens", + ["token_hash"], + unique=True, + schema="core", + ) + op.create_index( + "ix_core_invite_tokens_tenant_slug", + "invite_tokens", + ["tenant_slug"], + schema="core", + ) + op.add_column( + 'invite_tokens', + sa.Column('hub_invite_token', sa.String(length=255), nullable=True), + schema='core', + ) + + + +def downgrade() -> None: + op.drop_index( + "ix_core_invite_tokens_tenant_slug", + table_name="invite_tokens", + schema="core", + ) + op.drop_index( + "ix_core_invite_tokens_token_hash", + table_name="invite_tokens", + schema="core", + ) + op.drop_table("invite_tokens", schema="core") diff --git a/backend/alembic/versions/c3d4e5f6a7b_add_workspace_profile_fields_to_user_tenants.py b/backend/alembic/versions/c3d4e5f6a7b_add_workspace_profile_fields_to_user_tenants.py new file mode 100644 index 0000000..7608f78 --- /dev/null +++ b/backend/alembic/versions/c3d4e5f6a7b_add_workspace_profile_fields_to_user_tenants.py @@ -0,0 +1,56 @@ +"""add workspace profile fields to user_tenants + +Revision ID: c3d4e5f6a7b +Revises: b2c3d4e5f6a7 +Create Date: 2026-05-08 00:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "c3d4e5f6a7b" +down_revision: Union[str, None] = "b2c3d4e5f6a7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "user_tenants", + sa.Column( + "workspace_user_id", + sa.String(length=255), + nullable=True, + comment="User ID (sub) proveniente de Workspace", + ), + schema="core", + ) + op.add_column( + "user_tenants", + sa.Column( + "workspace_avatar_url", + sa.String(length=500), + nullable=True, + comment="Avatar URL sincronizado desde Workspace", + ), + schema="core", + ) + op.add_column( + "user_tenants", + sa.Column( + "workspace_profile_synced_at", + sa.DateTime(timezone=True), + nullable=True, + comment="Última sincronización de perfil con Workspace", + ), + schema="core", + ) + + +def downgrade() -> None: + op.drop_column("user_tenants", "workspace_profile_synced_at", schema="core") + op.drop_column("user_tenants", "workspace_avatar_url", schema="core") + op.drop_column("user_tenants", "workspace_user_id", schema="core") diff --git a/backend/alembic/versions/g2h3i4j5k6l7_add_invite_codes.py b/backend/alembic/versions/g2h3i4j5k6l7_add_invite_codes.py new file mode 100644 index 0000000..a6da363 --- /dev/null +++ b/backend/alembic/versions/g2h3i4j5k6l7_add_invite_codes.py @@ -0,0 +1,54 @@ +"""add invite codes + +Revision ID: g2h3i4j5k6l7 +Revises: c3d4e5f6a7b +Create Date: 2026-06-02 00:00:00.000000 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "g2h3i4j5k6l7" +down_revision: str = "c3d4e5f6a7b" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "invite_codes", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("code", sa.String(length=16), nullable=False), + sa.Column("tenant_slug", sa.String(length=100), nullable=False), + sa.Column("company_id", sa.Integer(), nullable=True), + sa.Column("role", sa.String(length=50), nullable=False, server_default="user"), + sa.Column("max_uses", sa.Integer(), nullable=True), + sa.Column("uses_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_by", sa.String(length=255), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"), + sa.Column("created_at", sa.DateTime(), nullable=True, server_default=sa.text("now()")), + sa.Column("updated_at", sa.DateTime(), nullable=True, server_default=sa.text("now()")), + sa.PrimaryKeyConstraint("id"), + schema="core", + ) + op.create_index("ix_core_invite_codes_id", "invite_codes", ["id"], schema="core") + op.create_index( + "ix_core_invite_codes_code", "invite_codes", ["code"], unique=True, schema="core" + ) + op.create_index( + "ix_core_invite_codes_tenant_slug", "invite_codes", ["tenant_slug"], schema="core" + ) + op.create_index( + "ix_core_invite_codes_company_id", "invite_codes", ["company_id"], schema="core" + ) + + +def downgrade() -> None: + op.drop_index("ix_core_invite_codes_company_id", table_name="invite_codes", schema="core") + op.drop_index("ix_core_invite_codes_tenant_slug", table_name="invite_codes", schema="core") + op.drop_index("ix_core_invite_codes_code", table_name="invite_codes", schema="core") + op.drop_index("ix_core_invite_codes_id", table_name="invite_codes", schema="core") + op.drop_table("invite_codes", schema="core") diff --git a/backend/api/__init__.py b/backend/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/v1/__init__.py b/backend/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/v1/common/base_models.py b/backend/api/v1/common/base_models.py new file mode 100644 index 0000000..31db7e7 --- /dev/null +++ b/backend/api/v1/common/base_models.py @@ -0,0 +1,33 @@ +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.sql import func + + +class BaseTimestampMixin: + """Mixin for basic timestamp fields (no soft delete)""" + + created_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=func.now(), onupdate=func.now() + ) + + +class TimestampMixin(BaseTimestampMixin): + """Mixin for common timestamp fields including soft delete""" + + deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + +class TenantScopedMixin: + """Mixin para entidades multi-tenant. + + company_id no tiene FK declarada aquí — agrégala en cada modelo + apuntando a la tabla de compañías de tu proyecto. + """ + + tenant_id: Mapped[int] = mapped_column(Integer, ForeignKey("core.tenants.id"), nullable=False, index=True) + company_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True) diff --git a/backend/api/v1/common/catalog_validation_errors.py b/backend/api/v1/common/catalog_validation_errors.py new file mode 100644 index 0000000..ee12b0a --- /dev/null +++ b/backend/api/v1/common/catalog_validation_errors.py @@ -0,0 +1,12 @@ +"""Errores de validación alineados a reglas CSV / catálogos (HTTP 422).""" + +from typing import Any, Dict, List + + +class CatalogValidationError(Exception): + """Lista de errores tipo {line, col, msg} como en import CSV.""" + + def __init__(self, errors: List[Dict[str, Any]]): + self.errors = errors or [] + first = self.errors[0].get("msg", "Validación de catálogo") if self.errors else "Validación de catálogo" + super().__init__(first) diff --git a/backend/api/v1/common/crud_routes.py b/backend/api/v1/common/crud_routes.py new file mode 100644 index 0000000..2f20863 --- /dev/null +++ b/backend/api/v1/common/crud_routes.py @@ -0,0 +1,121 @@ +from typing import Any, Callable, Generic, Type, TypeVar + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.orm import Session + +ModelType = TypeVar("ModelType") +CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel) +UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel) +ResponseSchemaType = TypeVar("ResponseSchemaType", bound=BaseModel) + + +class CRUDRouterFactory( + Generic[ModelType, CreateSchemaType, UpdateSchemaType, ResponseSchemaType] +): + """Factory to create standard CRUD routes""" + + def __init__( + self, + model: Type[ModelType], + create_schema: Type[CreateSchemaType], + update_schema: Type[UpdateSchemaType], + response_schema: Type[ResponseSchemaType], + db_dependency: Callable, + auth_dependency: Callable, + prefix: str, + tags: list[str], + id_field: str = "key", + ): + self.model = model + self.create_schema = create_schema + self.update_schema = update_schema + self.response_schema = response_schema + self.db_dependency = db_dependency + self.auth_dependency = auth_dependency + self.id_field = id_field + self.router = APIRouter(prefix=prefix, tags=tags) + self._register_routes() + + def _register_routes(self): + """Register all CRUD routes""" + + @self.router.get("/", response_model=list[self.response_schema]) + def list_items( + skip: int = 0, + limit: int = 100, + db: Session = Depends(self.db_dependency), + current_user: dict = Depends(self.auth_dependency), + ): + items = db.query(self.model).offset(skip).limit(limit).all() + return items + + @self.router.get(f"/{{{self.id_field}}}", response_model=self.response_schema) + def get_item( + db: Session = Depends(self.db_dependency), + current_user: dict = Depends(self.auth_dependency), + **kwargs, + ): + item_id = kwargs.get(self.id_field) + obj = ( + db.query(self.model) + .filter(getattr(self.model, self.id_field) == item_id) + .first() + ) + if not obj: + raise HTTPException(status_code=404, detail="Not found") + return obj + + @self.router.post("/", response_model=self.response_schema) + def create_item( + data: Any, + db: Session = Depends(self.db_dependency), + current_user: dict = Depends(self.auth_dependency), + ): + obj = self.model(**data.dict()) + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + @self.router.put(f"/{{{self.id_field}}}", response_model=self.response_schema) + def update_item( + data: Any, + db: Session = Depends(self.db_dependency), + current_user: dict = Depends(self.auth_dependency), + **kwargs, + ): + item_id = kwargs.get(self.id_field) + obj = ( + db.query(self.model) + .filter(getattr(self.model, self.id_field) == item_id) + .first() + ) + if not obj: + raise HTTPException(status_code=404, detail="Not found") + + for field, value in data.dict(exclude_unset=True).items(): + setattr(obj, field, value) + + db.commit() + db.refresh(obj) + return obj + + @self.router.delete(f"/{{{self.id_field}}}", status_code=204) + def delete_item( + db: Session = Depends(self.db_dependency), + current_user: dict = Depends(self.auth_dependency), + **kwargs, + ): + item_id = kwargs.get(self.id_field) + obj = ( + db.query(self.model) + .filter(getattr(self.model, self.id_field) == item_id) + .first() + ) + if not obj: + raise HTTPException(status_code=404, detail="Not found") + + db.delete(obj) + db.commit() + return None diff --git a/backend/api/v1/common/dto_mixins.py b/backend/api/v1/common/dto_mixins.py new file mode 100644 index 0000000..335af83 --- /dev/null +++ b/backend/api/v1/common/dto_mixins.py @@ -0,0 +1,32 @@ +from decimal import Decimal +from typing import Optional + +from pydantic import Field + + +class CurrencyMixin: + """Mixin for currency-related fields""" + + currency: Optional[str] = Field(None, max_length=3, description="Currency") + currency_factor: Optional[Decimal] = Field(None, description="Currency factor") + + +class AffectValueMixin: + """Mixin for value affect flags""" + + not_affect_usd_value: Optional[bool] = Field( + None, description="Not affect USD value" + ) + not_affect_customs_value: Optional[bool] = Field( + None, description="Not affect customs value" + ) + + +class UpdateFlagsMixin: + """Mixin for update flags""" + + update_vat: Optional[bool] = Field(None, description="Update VAT") + update_advalorem: Optional[bool] = Field(None, description="Update advalorem") + update_dta: Optional[bool] = Field(None, description="Update DTA") + update_cc: Optional[bool] = Field(None, description="Update CC") + update_ieps: Optional[bool] = Field(None, description="Update IEPS") diff --git a/backend/api/v1/common/tenant_crud_routes.py b/backend/api/v1/common/tenant_crud_routes.py new file mode 100644 index 0000000..6297394 --- /dev/null +++ b/backend/api/v1/common/tenant_crud_routes.py @@ -0,0 +1,651 @@ +from typing import Any, Callable, Dict, Generic, Optional, Type, TypeVar, Union +import logging +import inspect + +from core.database import get_core_db +from core.security import get_current_user, is_hub_admin, resolve_tenant_id_required, validate_access_to_resource, get_active_system +from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Request + +from api.v1.common.catalog_validation_errors import CatalogValidationError +from pydantic import BaseModel +from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + +# Type variables for generic types +ModelType = TypeVar("ModelType") +CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel) +UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel) +ResponseSchemaType = TypeVar("ResponseSchemaType", bound=BaseModel) +ServiceType = TypeVar("ServiceType") + + +class TenantCRUDRoutes( + Generic[CreateSchemaType, UpdateSchemaType, ResponseSchemaType, ServiceType] +): + """ + Generic CRUD routes factory for tenant-scoped resources + + Supports both parent resources (with list/pagination) and child resources (nested under parent). + + Usage examples: + + 1. Parent resource with list (e.g., /pedimentos): + router = TenantCRUDRoutes( + service=PedimentosService, + create_schema=PedimentosCreate, + update_schema=PedimentosUpdate, + response_schema=PedimentosResponse, + prefix="/pedimentos", + tags=["Pedimentos"], + resource_name="Pedimento", + id_name="pedimento_id", + enable_list=True, + ).router + + 2. Child resource (e.g., /pedimentos/{pedimento_id}/config-additional): + router = TenantCRUDRoutes( + service=PedimentoConfigAdditionalService, + create_schema=PedimentoConfigAdditionalCreate, + update_schema=PedimentoConfigAdditionalUpdate, + response_schema=PedimentoConfigAdditionalResponse, + prefix="/{pedimento_id}/config-additional", + tags=["Pedimento Config Additional"], + resource_name="Config additional", + parent_id_name="pedimento_id", + enable_list=False, + ).router + + 3. Parent resource with string ID (e.g., /vehicles with vehicle_key): + router = TenantCRUDRoutes( + service=VehicleService, + create_schema=VehicleCreate, + update_schema=VehicleUpdate, + response_schema=VehicleResponse, + prefix="/vehicles", + tags=["Vehicles"], + resource_name="Vehicle", + id_name="vehicle_key", + id_type=str, # Specify string type for vehicle_key + enable_list=True, + ).router + """ + + def __init__( + self, + service: Type[ServiceType], + create_schema: Type[CreateSchemaType], + update_schema: Type[UpdateSchemaType], + response_schema: Type[ResponseSchemaType], + prefix: str, + tags: list[str], + resource_name: str = "Resource", + # For parent resources (e.g., "pedimento_id") + id_name: Optional[str] = None, + id_type: Type = int, # Type of the ID (int, str, etc.) + parent_id_name: Optional[ + str + ] = None, # For child resources (e.g., "pedimento_id") + db_dependency: Callable = get_core_db, + auth_dependency: Callable = get_current_user, + validate_parent_match: bool = True, # Validate parent_id matches in create + enable_list: bool = False, # Enable GET list endpoint with pagination + enable_filters: bool = False, # Enable custom filters in list endpoint + default_page_size: int = 50, + max_page_size: int = 2000, + # Permissions for each operation + list_permissions: Optional[list[str]] = None, + get_permissions: Optional[list[str]] = None, + create_permissions: Optional[list[str]] = None, + update_permissions: Optional[list[str]] = None, + delete_permissions: Optional[list[str]] = None, + require_all: bool = True, # If True, requires ALL permissions; if False, requires ANY + ): + self.service = service + self.create_schema = create_schema + self.update_schema = update_schema + self.response_schema = response_schema + self.resource_name = resource_name + self.id_name = id_name or parent_id_name or "id" + self.id_type = id_type + self.parent_id_name = parent_id_name + self.db_dependency = db_dependency + self.auth_dependency = auth_dependency + self.validate_parent_match = validate_parent_match + self.enable_list = enable_list + self.enable_filters = enable_filters + self.default_page_size = default_page_size + self.max_page_size = max_page_size + self.list_permissions = list_permissions + self.get_permissions = get_permissions + self.create_permissions = create_permissions + self.update_permissions = update_permissions + self.delete_permissions = delete_permissions + self.require_all = require_all + + self.router = APIRouter(prefix=prefix, tags=tags) + self._register_routes() + + def _register_routes(self): + """Register all CRUD routes""" + + # LIST route (optional, for parent resources) + if self.enable_list: + if self.enable_filters: + + @self.router.get( + "/", + response_model=Dict[str, Any], + summary=f"List {self.resource_name}s", + description=f"Get paginated list of {self.resource_name}s with optional filters", + ) + async def list_resources( + request: Request, + company_id: int = Query(..., description="Company ID"), + all_companies: bool = Query(False, description="Whether to search in all companies of the tenant"), + page: int = Query(1, ge=1, description="Page number"), + page_size: int = Query( + self.default_page_size, + ge=1, + le=self.max_page_size, + description="Page size", + ), + sort_by: Optional[str] = Query(None, description="Column to sort by"), + sort_order: Optional[str] = Query("asc", pattern="^(asc|desc)$", description="Sort order (asc or desc)"), + db: Session = Depends(self.db_dependency), + current_user: Dict[str, Any] = Depends(self.auth_dependency), + ): + if all_companies: + # Hub admin: tenant_id=None → el servicio devuelve todas las empresas + tenant_id = resolve_tenant_id_required(current_user, db=db) + target_company_id = None + else: + tenant_id = validate_access_to_resource( + db, + company_id, + current_user, + self.list_permissions, + self.require_all, + ) + target_company_id = company_id + + skip = (page - 1) * page_size + + # Extraer todos los parámetros de búsqueda dinámicamente + # Excluimos los parámetros estándar de paginación y control + standard_params = {"company_id", "all_companies", "page", "page_size", "sort_by", "sort_order"} + filters = { + k: v + for k, v in request.query_params.items() + if k not in standard_params and v is not None and v != "" + } + + # Inyectar active_system (header/cookie) si no viene por query param + active_system = get_active_system(request) + if active_system and "system" not in filters: + filters["system"] = active_system + + # Determine what parameters the service method accepts + sig = inspect.signature(self.service.get_all) + kwargs = {} + if "sort_by" in sig.parameters: + kwargs["sort_by"] = sort_by + if "sort_order" in sig.parameters: + kwargs["sort_order"] = sort_order + + try: + items, total = self.service.get_all( + db, tenant_id, target_company_id, skip, page_size, filters, **kwargs + ) + except Exception as e: + logger.error(f"Error in {self.resource_name} list service: {str(e)}", exc_info=True) + raise HTTPException( + status_code=500, + detail=f"Error listing {self.resource_name}s: {str(e)}" + ) + + try: + return { + "items": [ + self.response_schema.model_validate(item) for item in items + ], + "total": total, + "page": page, + "page_size": page_size, + } + except Exception as e: + logger.error(f"Error validating {self.resource_name} response schema: {str(e)}", exc_info=True) + raise HTTPException( + status_code=500, + detail=f"Data validation error in {self.resource_name}" + ) + + else: + + @self.router.get( + "/", + response_model=Dict[str, Any], + summary=f"List {self.resource_name}s", + description=f"Get paginated list of {self.resource_name}s", + ) + async def list_resources( + company_id: int = Query(..., description="Company ID"), + all_companies: bool = Query(False, description="Whether to search in all companies of the tenant"), + page: int = Query(1, ge=1, description="Page number"), + page_size: int = Query( + self.default_page_size, + ge=1, + le=self.max_page_size, + description="Page size", + ), + sort_by: Optional[str] = Query(None, description="Column to sort by"), + sort_order: Optional[str] = Query("asc", pattern="^(asc|desc)$", description="Sort order (asc or desc)"), + db: Session = Depends(self.db_dependency), + current_user: Dict[str, Any] = Depends(self.auth_dependency), + ): + if all_companies: + # Hub admin: tenant_id=None → el servicio devuelve todas las empresas + tenant_id = resolve_tenant_id_required(current_user, db=db) + target_company_id = None + else: + tenant_id = validate_access_to_resource( + db, + company_id, + current_user, + self.list_permissions, + self.require_all, + ) + target_company_id = company_id + + skip = (page - 1) * page_size + + # Determine what parameters the service method accepts + sig = inspect.signature(self.service.get_all) + kwargs = {} + if "sort_by" in sig.parameters: + kwargs["sort_by"] = sort_by + if "sort_order" in sig.parameters: + kwargs["sort_order"] = sort_order + + try: + items, total = self.service.get_all( + db, tenant_id, target_company_id, skip, page_size, None, **kwargs + ) + except Exception as e: + logger.error(f"Error in {self.resource_name} list service: {str(e)}", exc_info=True) + raise HTTPException( + status_code=500, + detail=f"Error listing {self.resource_name}s: {str(e)}" + ) + + try: + return { + "items": [ + self.response_schema.model_validate(item) for item in items + ], + "total": total, + "page": page, + "page_size": page_size, + } + except Exception as e: + logger.error(f"Error validating {self.resource_name} response schema: {str(e)}", exc_info=True) + raise HTTPException( + status_code=500, + detail=f"Data validation error in {self.resource_name}" + ) + + # GET single resource route + # For parent resources: GET /{id} + # For child resources: GET / (parent_id comes from path) + if self.parent_id_name: + # Child resource - single GET without ID in path + @self.router.get( + "/", + response_model=self.response_schema, + summary=f"Get {self.resource_name}", + description=f"Get {self.resource_name} by {self.parent_id_name}", + ) + async def get_resource( + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(self.db_dependency), + current_user: Dict[str, Any] = Depends(self.auth_dependency), + **path_params, + ): + + tenant_id = validate_access_to_resource( + db, company_id, current_user, self.get_permissions, self.require_all + ) + parent_id = path_params.get(self.parent_id_name) + + # Try method with 4 params (pedimento_id, tenant_id, company_id) + if hasattr(self.service, "get_by_pedimento_id"): + resource = self.service.get_by_pedimento_id( + db, parent_id, tenant_id, company_id + ) + # Fallback to method with 3 params + elif hasattr(self.service, "get_by_id"): + resource = self.service.get_by_id( + db, parent_id, tenant_id, company_id + ) + else: + resource = self.service.get(db, parent_id, tenant_id, company_id) + + if not resource: + raise HTTPException( + status_code=404, detail=f"{self.resource_name} not found" + ) + return resource + + else: + # Parent resource - GET by ID in path + @self.router.get( + f"/{{{self.id_name}}}", + response_model=self.response_schema, + summary=f"Get {self.resource_name} by ID", + description=f"Get a specific {self.resource_name} by {self.id_name}", + ) + async def get_resource_by_id( + resource_id: Union[int, str] = Path( + ..., alias=self.id_name, description=f"{self.resource_name} ID" + ), + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(self.db_dependency), + current_user: Dict[str, Any] = Depends(self.auth_dependency), + ): + tenant_id = validate_access_to_resource( + db, company_id, current_user, self.get_permissions, self.require_all + ) + + try: + resource = self.service.get_by_id( + db, resource_id, tenant_id, company_id + ) + except Exception as e: + logger.error(f"Error in {self.resource_name} get service: {str(e)}", exc_info=True) + raise HTTPException( + status_code=500, + detail=f"Error retrieving {self.resource_name}: {str(e)}" + ) + + if not resource: + raise HTTPException( + status_code=404, detail=f"{self.resource_name} not found" + ) + return resource + + # POST route + if self.parent_id_name: + # Child resource - needs parent_id from path + + # Create a closure to capture the schema type + create_schema = self.create_schema + + @self.router.post( + "/", + response_model=self.response_schema, + status_code=201, + summary=f"Create {self.resource_name}", + description=f"Create a new {self.resource_name}", + ) + async def create_child_resource( + request: Request, + company_id: int = Query(..., description="Company ID"), + data: create_schema = Body(...), # type: ignore + db: Session = Depends(self.db_dependency), + current_user: Dict[str, Any] = Depends(self.auth_dependency), + ): + tenant_id = validate_access_to_resource( + db, + company_id, + current_user, + self.create_permissions, + self.require_all, + ) + + # Inyectar sistema activo en el campo system si el recurso lo soporta + if self.enable_filters: + active_system = get_active_system(request) + if active_system and hasattr(data, "system"): + data = data.model_copy(update={"system": active_system}) + + # For child resources, parent_id validation would go here + try: + resource = self.service.create(db, data, tenant_id, company_id) + return resource + except CatalogValidationError as e: + raise HTTPException( + status_code=422, + detail={ + "message": str(e), + "errors": e.errors, + }, + ) + except ValueError as e: + # Capturar errores de validación (como duplicados) + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + # Re-lanzar otros errores + raise + + else: + # Parent resource - no parent_id needed + + # Create a closure to capture the schema type + create_schema = self.create_schema + + @self.router.post( + "/", + response_model=self.response_schema, + status_code=201, + summary=f"Create {self.resource_name}", + description=f"Create a new {self.resource_name}", + ) + async def create_parent_resource( + request: Request, + company_id: int = Query(..., description="Company ID"), + data: create_schema = Body(...), # type: ignore + db: Session = Depends(self.db_dependency), + current_user: Dict[str, Any] = Depends(self.auth_dependency), + ): + tenant_id = validate_access_to_resource( + db, + company_id, + current_user, + self.create_permissions, + self.require_all, + ) + + # Inyectar sistema activo en el campo system si el recurso lo soporta + if self.enable_filters: + active_system = get_active_system(request) + if active_system and hasattr(data, "system"): + data = data.model_copy(update={"system": active_system}) + + try: + resource = self.service.create(db, data, tenant_id, company_id) + return resource + except CatalogValidationError as e: + raise HTTPException( + status_code=422, + detail={ + "message": str(e), + "errors": e.errors, + }, + ) + except ValueError as e: + # Capturar errores de validación (como duplicados) + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + # Re-lanzar otros errores + raise + + # PUT route + # For parent resources: PUT /{id} + # For child resources: PUT / (parent_id comes from path) + if self.parent_id_name: + # Child resource + + # Create a closure to capture the schema type + update_schema = self.update_schema + + @self.router.put( + "/", + response_model=self.response_schema, + summary=f"Update {self.resource_name}", + description=f"Update an existing {self.resource_name}", + ) + async def update_resource( + company_id: int = Query(..., description="Company ID"), + data: update_schema = Body(...), # type: ignore + db: Session = Depends(self.db_dependency), + current_user: Dict[str, Any] = Depends(self.auth_dependency), + **path_params, + ): + tenant_id = validate_access_to_resource( + db, + company_id, + current_user, + self.update_permissions, + self.require_all, + ) + parent_id = path_params.get(self.parent_id_name) + + try: + resource = self.service.update( + db, parent_id, tenant_id, data, company_id + ) + except CatalogValidationError as e: + raise HTTPException( + status_code=422, + detail={ + "message": str(e), + "errors": e.errors, + }, + ) + except ValueError as e: + # Capturar errores de validación (como duplicados) + raise HTTPException(status_code=400, detail=str(e)) + + if not resource: + raise HTTPException( + status_code=404, detail=f"{self.resource_name} not found" + ) + return resource + + else: + # Parent resource + + # Create a closure to capture the schema type + update_schema = self.update_schema + + @self.router.put( + f"/{{{self.id_name}}}/", + response_model=self.response_schema, + summary=f"Update {self.resource_name}", + description=f"Update an existing {self.resource_name} by {self.id_name}", + ) + async def update_resource_by_id( + resource_id: Union[int, str] = Path( + ..., alias=self.id_name, description=f"{self.resource_name} ID" + ), + company_id: int = Query(..., description="Company ID"), + data: update_schema = Body(...), # type: ignore + db: Session = Depends(self.db_dependency), + current_user: Dict[str, Any] = Depends(self.auth_dependency), + ): + f"""Update {self.resource_name}""" + tenant_id = validate_access_to_resource( + db, + company_id, + current_user, + self.update_permissions, + self.require_all, + ) + + try: + resource = self.service.update( + db, resource_id, tenant_id, data, company_id + ) + except CatalogValidationError as e: + raise HTTPException( + status_code=422, + detail={ + "message": str(e), + "errors": e.errors, + }, + ) + except ValueError as e: + # Capturar errores de validación (como duplicados) + raise HTTPException(status_code=400, detail=str(e)) + + if not resource: + raise HTTPException( + status_code=404, detail=f"{self.resource_name} not found" + ) + return resource + + # DELETE route + # For parent resources: DELETE /{id} + # For child resources: DELETE / (parent_id comes from path) + if self.parent_id_name: + # Child resource + @self.router.delete( + "/", + status_code=204, + summary=f"Delete {self.resource_name}", + description=f"Delete an existing {self.resource_name}", + ) + async def delete_resource( + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(self.db_dependency), + current_user: Dict[str, Any] = Depends(self.auth_dependency), + **path_params, + ): + tenant_id = validate_access_to_resource( + db, + company_id, + current_user, + self.delete_permissions, + self.require_all, + ) + parent_id = path_params.get(self.parent_id_name) + + success = self.service.delete(db, parent_id, tenant_id, company_id) + + if not success: + raise HTTPException( + status_code=404, detail=f"{self.resource_name} not found" + ) + return None + + else: + # Parent resource + @self.router.delete( + f"/{{{self.id_name}}}", + status_code=204, + summary=f"Delete {self.resource_name}", + description=f"Delete an existing {self.resource_name} by {self.id_name}", + ) + async def delete_resource_by_id( + resource_id: Union[int, str] = Path( + ..., alias=self.id_name, description=f"{self.resource_name} ID" + ), + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(self.db_dependency), + current_user: Dict[str, Any] = Depends(self.auth_dependency), + ): + tenant_id = validate_access_to_resource( + db, + company_id, + current_user, + self.delete_permissions, + self.require_all, + ) + + success = self.service.delete(db, resource_id, tenant_id, company_id) + + if not success: + raise HTTPException( + status_code=404, detail=f"{self.resource_name} not found" + ) + return None diff --git a/backend/api/v1/modules/__init__.py b/backend/api/v1/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/v1/modules/core/auth/__init__.py b/backend/api/v1/modules/core/auth/__init__.py new file mode 100644 index 0000000..a4f6282 --- /dev/null +++ b/backend/api/v1/modules/core/auth/__init__.py @@ -0,0 +1,7 @@ +""" +Módulo de Authentication +""" + +from .routes import router + +__all__ = ["router"] diff --git a/backend/api/v1/modules/core/auth/dto.py b/backend/api/v1/modules/core/auth/dto.py new file mode 100644 index 0000000..a9bad09 --- /dev/null +++ b/backend/api/v1/modules/core/auth/dto.py @@ -0,0 +1,210 @@ +""" +DTOs para módulo de autenticación +""" + +from typing import Optional + +from pydantic import BaseModel, EmailStr, Field + + +class LoginRequestDTO(BaseModel): + """DTO para solicitud de login""" + + username: str = Field(..., description="Usuario o email") + password: str = Field(..., min_length=6, description="Contraseña") + # Opcional en el primer paso: si no se provee, el backend verifica credenciales + # y devuelve la lista de tenants disponibles en lugar de tokens. + tenant_slug: Optional[str] = Field(None, description="Slug del tenant") + + class Config: + json_schema_extra = { + "example": { + "username": "usuario@ejemplo.com", + "password": "password123", + "tenant_slug": "empresa-abc", + } + } + + +class TokenResponseDTO(BaseModel): + """DTO para respuesta de token""" + + access_token: str + refresh_token: str + token_type: str = "bearer" + expires_in: int + tenant: Optional["TenantInfoDTO"] = None + tenant_id: Optional[int] = None + tenant_slug: Optional[str] = None + + class Config: + json_schema_extra = { + "example": { + "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", + "refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", + "token_type": "bearer", + "expires_in": 3600, + } + } + + +class RefreshTokenRequestDTO(BaseModel): + """DTO para solicitud de refresh token""" + + refresh_token: str = Field(..., description="Refresh token") + + +class UserInfoResponseDTO(BaseModel): + """DTO para información de usuario""" + + sub: str + email: Optional[str] = None + name: Optional[str] = None + preferred_username: Optional[str] = None + tenant_id: Optional[int] = None + tenant_slug: Optional[str] = None + avatar_url: Optional[str] = None + roles: list[str] = [] + permissions: list[str] = [] + + class Config: + json_schema_extra = { + "example": { + "sub": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "email": "usuario@ejemplo.com", + "name": "Juan Pérez", + "preferred_username": "jperez", + "tenant_id": 1, + "roles": ["user", "admin"], + "permissions": ["cat_ports.view", "cat_ports.create"] + } + } + + +class LogoutRequestDTO(BaseModel): + """DTO para solicitud de logout""" + + refresh_token: str = Field(..., description="Refresh token para invalidar") + username: Optional[str] = Field(None, description="Nombre de usuario para auditoría") + + +class RegisterRequestDTO(BaseModel): + """DTO para solicitud de registro""" + + username: str = Field( + ..., min_length=3, max_length=50, description="Nombre de usuario" + ) + email: EmailStr = Field(..., description="Email del usuario") + password: str = Field(..., min_length=8, description="Contraseña") + first_name: str = Field(..., min_length=2, max_length=50, description="Nombre") + last_name: str = Field(..., min_length=2, max_length=50, description="Apellido") + tenant_slug: str = Field(..., description="Slug del tenant") + invite_token: Optional[str] = Field(None, description="Token de invitación local (opcional)") + + class Config: + json_schema_extra = { + "example": { + "username": "jperez", + "email": "jperez@ejemplo.com", + "password": "MiPassword123!", + "first_name": "Juan", + "last_name": "Pérez", + "tenant_slug": "empresa-abc", + } + } + + +class RegisterResponseDTO(BaseModel): + """DTO para respuesta de registro""" + + user_id: str + username: str + email: str + message: str + + class Config: + json_schema_extra = { + "example": { + "user_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "username": "jperez", + "email": "jperez@ejemplo.com", + "message": "User registered successfully", + } + } + + +class ExchangeCodeRequestDTO(BaseModel): + """DTO para intercambiar authorization code por tokens (OAuth2 flow)""" + + code: str = Field(..., description="Authorization code de OAuth2") + redirect_uri: str = Field(..., description="Redirect URI usado en la autorización") + tenant_slug: Optional[str] = Field(None, description="Slug del tenant (opcional)") + + class Config: + json_schema_extra = { + "example": { + "code": "eyJhbGciOiJkaXIiLCJlbmMiOiJBMTI4Q0JDLUhTMjU2Ii...", + "redirect_uri": "http://localhost:5173/auth/callback", + "tenant_slug": "empresa-abc", + } + } + + +class SetCookieRequestDTO(BaseModel): + """DTO para establecer cookies de autenticación""" + + access_token: str = Field(..., description="Access token JWT") + refresh_token: str = Field(..., description="Refresh token JWT") + + +class SwitchTenantRequestDTO(BaseModel): + """DTO para cambiar de tenant estando autenticado""" + + tenant_slug: str = Field(..., description="Slug del tenant destino") + refresh_token: str = Field(..., description="Refresh token actual para emitir nuevos tokens") + + +class DiscoverTenantsRequestDTO(BaseModel): + """DTO para descubrir los tenants de un usuario sin necesidad de indicarlo manualmente""" + + username: str = Field(..., description="Nombre de usuario o email") + + class Config: + json_schema_extra = { + "example": { + "username": "jperez", + } + } + + +class TenantInfoDTO(BaseModel): + """Información básica de un tenant para mostrar en el selector de login""" + + id: int + name: str + slug: str + + class Config: + from_attributes = True + + +class DiscoverTenantsResponseDTO(BaseModel): + """Respuesta con los tenants disponibles para un usuario""" + + tenants: list[TenantInfoDTO] + + +class LoginChoiceResponseDTO(BaseModel): + """ + Respuesta del login cuando el usuario pertenece a varios tenants. + Las credenciales ya fueron verificadas; el cliente debe re-enviar con tenant_slug. + """ + + status: str = "choose_tenant" + tenants: list[TenantInfoDTO] + + +class SSOExchangeRequestDTO(BaseModel): + """DTO para canjear el relay token por KC tokens.""" + + relay_token: str = Field(..., description="Relay token recibido en la URL") diff --git a/backend/api/v1/modules/core/auth/routes.py b/backend/api/v1/modules/core/auth/routes.py new file mode 100644 index 0000000..f566340 --- /dev/null +++ b/backend/api/v1/modules/core/auth/routes.py @@ -0,0 +1,422 @@ +""" +Endpoints API para autenticación +""" + +from core.database import get_core_db +from core.security import get_current_user +from fastapi import APIRouter, Depends, HTTPException, Query, Response, Request +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy.orm import Session + +from .dto import ( + ExchangeCodeRequestDTO, + LoginChoiceResponseDTO, + LoginRequestDTO, + LogoutRequestDTO, + RefreshTokenRequestDTO, + RegisterRequestDTO, + RegisterResponseDTO, + SetCookieRequestDTO, + SSOExchangeRequestDTO, + SwitchTenantRequestDTO, + TokenResponseDTO, + UserInfoResponseDTO, +) +from .service import AuthService + +router = APIRouter(prefix="/auth", tags=["Authentication"]) +security = HTTPBearer() + + +@router.get("/register/check") +async def check_register( + invite_token: str = Query(..., description="Token de invitación"), + tenant_slug: str = Query(..., description="Slug del tenant"), + email: str = Query(..., description="Email del usuario invitado"), + db: Session = Depends(get_core_db), +): + """ + Valida un token de invitación y verifica si el email ya existe en Keycloak. + No consume el token. Responde con user_exists y datos básicos del usuario si ya existe. + """ + from api.v1.modules.core.invites.service import InviteService + import httpx + from core.config import settings + + invite_service = InviteService(db) + # Valida token (lanza 403 si es inválido) + invite_result = invite_service.validate(invite_token, tenant_slug, email) + + # Intentar verificar si el email ya existe en el Hub usando service account + user_exists = False + user_info: dict = {} + if settings.HUB_ADMIN_EMAIL and settings.HUB_ADMIN_PASSWORD: + try: + async with httpx.AsyncClient(timeout=10.0) as client: + # Login con service account + login_resp = await client.post( + f"{settings.HUB_URL}api/v1/auth/login", + json={"username": settings.HUB_ADMIN_EMAIL, "password": settings.HUB_ADMIN_PASSWORD}, + ) + if login_resp.status_code == 200: + svc_token = login_resp.json().get("access_token", "") + if svc_token: + # Buscar admin por email + admins_resp = await client.get( + f"{settings.HUB_URL}api/v1/hub/admins", + params={"email": email}, + headers={"Authorization": f"Bearer {svc_token}"}, + ) + if admins_resp.status_code == 200: + admins = admins_resp.json() + if isinstance(admins, list): + matches = [a for a in admins if a.get("email", "").lower() == email.lower()] + elif isinstance(admins, dict) and "items" in admins: + matches = [a for a in admins["items"] if a.get("email", "").lower() == email.lower()] + else: + matches = [] + if matches: + user_exists = True + a = matches[0] + user_info = { + "username": a.get("username", ""), + "first_name": a.get("first_name", ""), + "last_name": a.get("last_name", ""), + } + except Exception as exc: + import logging + logging.getLogger(__name__).warning("register/check Hub lookup failed: %s", exc) + + return { + "email": invite_result.email, + "role": invite_result.role, + "user_exists": user_exists, + **user_info, + } + + +@router.post("/register", response_model=RegisterResponseDTO, status_code=201) +async def register( + register_data: RegisterRequestDTO, db: Session = Depends(get_core_db) +): + """ + Registra un nuevo usuario en Keycloak + + El usuario debe proporcionar: + - username: Nombre de usuario único + - email: Email único + - password: Contraseña (mínimo 8 caracteres) + - first_name: Nombre + - last_name: Apellido + - tenant_slug: Slug del tenant al que pertenece + + El usuario se crea automáticamente en Keycloak con: + - Cuenta habilitada + - Rol 'user' asignado por defecto + - Atributos de tenant + """ + service = AuthService(db) + return await service.register(register_data) + + +@router.post("/login", response_model=None) +async def login( + login_data: LoginRequestDTO, + request: Request, # Inject Request + db: Session = Depends(get_core_db) +): + """ + Autentica usuario con Keycloak y retorna tokens JWT + + El usuario debe proporcionar: + - username: Usuario o email + - password: Contraseña + - tenant_slug: Slug del tenant al que pertenece + """ + service = AuthService(db) + import logging + logger = logging.getLogger(__name__) + return await service.login( + login_data=login_data, + ip_address=request.client.host, + user_agent=request.headers.get("user-agent") + ) + + +@router.post("/switch-tenant", response_model=TokenResponseDTO) +async def switch_tenant( + data: SwitchTenantRequestDTO, + db: Session = Depends(get_core_db), + credentials: HTTPAuthorizationCredentials = Depends(security), +): + """ + Cambia el tenant activo de un usuario ya autenticado y retorna nuevos tokens JWT. + + Requiere: + - Authorization: Bearer (para identificar al usuario) + - Body: { tenant_slug, refresh_token } + """ + service = AuthService(db) + # Obtener info del usuario desde el access token actual + user_info = await service.get_user_info(credentials.credentials) + + keycloak_user_id = user_info.sub + # El realm se puede inferir del token; usamos el campo tenant_id para buscar el realm actual, + # pero lo más directo es dejar que Keycloak lo resuelva usando la config global. + # Todos los tenants comparten el mismo realm en esta arquitectura. + from api.v1.modules.core.tenants.models import Tenant + from core.database import get_core_db as _gcdb + # Obtener el realm del tenant destino (o default) + tenant = db.query(Tenant).filter(Tenant.slug == data.tenant_slug, Tenant.is_active).first() + if not tenant: + raise HTTPException(status_code=403, detail="Access denied") + + return await service.switch_tenant( + keycloak_user_id=keycloak_user_id, + keycloak_realm=tenant.keycloak_realm, + tenant_slug=data.tenant_slug, + refresh_token=data.refresh_token, + ) + + +@router.post("/refresh", response_model=TokenResponseDTO) +async def refresh_token( + refresh_data: RefreshTokenRequestDTO, db: Session = Depends(get_core_db) +): + """ + Refresca el access token usando el refresh token + """ + service = AuthService(db) + return await service.refresh_token(refresh_data) + + +@router.get("/me", response_model=UserInfoResponseDTO) +async def get_current_user_info( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: Session = Depends(get_core_db), +): + """ + Obtiene información del usuario actual desde el token + """ + service = AuthService(db) + return await service.get_user_info(credentials.credentials) + + +@router.post("/lazy-link", status_code=200) +async def lazy_link( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: Session = Depends(get_core_db), +): + """ + Vincula un invite pendiente al usuario autenticado (lazy-link). + Se llama después de un SSO login desde el workspace para crear el UserTenant + si hay un invite_token pendiente para el email del usuario. + """ + service = AuthService(db) + try: + await service._link_pending_invite( + credentials.credentials, # username_or_email = token (fallback) + access_token=credentials.credentials, + ) + except Exception: + pass + try: + claims = service._decode_kc_user_from_token(credentials.credentials) + service._backfill_company_roles(claims.get("sub", "")) + except Exception: + pass + return {"ok": True} + + +@router.post("/logout") +async def logout( + logout_data: LogoutRequestDTO, + request: Request, # Inject request for IP/User-Agent + db: Session = Depends(get_core_db), + # Make current_user optional to avoid 401 on expired tokens + # We will try to use it if available, otherwise use DTO + # Note: Depends(get_current_user) raises HTTPException if invalid, so we cannot make it optional easily without changing dependency. + # Instead, we will rely on DTO username since user explicitly asked for this simplified flow. + # But if we want to support both, we can't use strict dependency here if we expect it to work on expired tokens. + # So we remove the strict dependency for now as per "simplified" request. +): + """ + Cierra sesión invalidando el refresh token + """ + # Extract info for logging (optional, but harmless to keep providing context if needed, + # but strictly speaking we can revert to just calling service) + # The original file likely didn't have IP extraction here unless I added it. + # I'll keep it simple. + + service = AuthService(db) + return await service.logout(logout_data) + + +@router.post("/exchange-code", response_model=TokenResponseDTO) +async def exchange_code( + exchange_data: ExchangeCodeRequestDTO, db: Session = Depends(get_core_db) +): + """ + Intercambia un authorization code de OAuth2 por tokens + + Este endpoint es útil cuando el frontend usa el flujo de autorización + con proveedores externos (Microsoft, Google, etc.) a través de Keycloak. + + El código se obtiene después de que el usuario se autentica con el proveedor + externo y Keycloak lo redirige al frontend con el código en los query params. + """ + service = AuthService(db) + return await service.exchange_code(exchange_data) + + +@router.post("/set-cookie") +async def set_cookie( + cookie_data: SetCookieRequestDTO, + response: Response, + db: Session = Depends(get_core_db), +): + """ + Establece cookies HttpOnly con los tokens de autenticación + + Este endpoint se llama desde el frontend después de una autenticación + SSO exitosa para establecer las cookies de sesión necesarias para + la validación server-side en los layouts protegidos. + + Las cookies se configuran como: + - HttpOnly: No accesibles desde JavaScript (mayor seguridad) + - Secure: Solo se envían por HTTPS (en producción) + - SameSite=Lax: Protección contra CSRF + - Max-Age: Tiempo de vida del token + """ + # Validar que los tokens sean válidos decodificándolos + service = AuthService(db) + try: + # Validar el access token + user_info = await service.get_user_info(cookie_data.access_token) + + # Establecer las cookies + # Access token cookie + response.set_cookie( + key="access_token", + value=cookie_data.access_token, + httponly=True, # No accesible desde JavaScript + secure=False, # TODO: Cambiar a True en producción con HTTPS + samesite="lax", # Protección CSRF + max_age=3600, # 1 hora (ajustar según configuración del token) + path="/", + ) + + # Refresh token cookie + response.set_cookie( + key="refresh_token", + value=cookie_data.refresh_token, + httponly=True, + secure=False, # TODO: Cambiar a True en producción con HTTPS + samesite="lax", + max_age=86400, # 24 horas (ajustar según configuración del token) + path="/", + ) + + return { + "success": True, + "message": "Cookies establecidas correctamente", + "user": user_info, + } + + except Exception as e: + raise HTTPException(status_code=400, detail=f"Error validando tokens: {str(e)}") + + +@router.post("/sso-exchange", response_model=TokenResponseDTO) +async def sso_exchange( + body: SSOExchangeRequestDTO, + response: Response, + db: Session = Depends(get_core_db), +): + """ + Canjea un relay token de un solo uso (generado por el Hub) por KC tokens. + Llamado server-side desde la página /auth/sso del frontend. + Establece cookies HttpOnly con los tokens y devuelve el resultado. + """ + service = AuthService(db) + tokens = await service.sso_exchange(body.relay_token) + + _is_prod = False # TODO: leer de settings.ENVIRONMENT == "production" + response.set_cookie( + key="access_token", + value=tokens.access_token, + httponly=True, + secure=_is_prod, + samesite="lax", + max_age=3600, + path="/", + ) + response.set_cookie( + key="refresh_token", + value=tokens.refresh_token, + httponly=True, + secure=_is_prod, + samesite="lax", + max_age=86400, + path="/", + ) + return tokens + + +# --------------------------------------------------------------------------- +# Dev-only local auth — solo disponible cuando DEV_LOCAL_AUTH=True +# --------------------------------------------------------------------------- +@router.post("/dev-login") +async def dev_login(): + """ + Genera un token local firmado con SECRET_KEY para desarrollo sin Keycloak/Hub. + Disponible únicamente cuando DEV_LOCAL_AUTH=True en el entorno. + """ + from datetime import datetime, timezone, timedelta + from jose import jwt as jose_jwt + from core.config import settings + + if not settings.DEV_LOCAL_AUTH: + raise HTTPException(status_code=404, detail="Not found") + + now = datetime.now(timezone.utc) + payload = { + "sub": "dev-local-user", + "email": settings.DEV_LOCAL_AUTH_EMAIL, + "preferred_username": settings.DEV_LOCAL_AUTH_EMAIL.split("@")[0], + "name": settings.DEV_LOCAL_AUTH_NAME, + "tenant_id": settings.DEV_LOCAL_AUTH_TENANT_ID, + "tenant_slug": "dev", + "company_id": settings.DEV_LOCAL_AUTH_COMPANY_ID, + "roles": ["super_admin"], + "permissions": [], + "allowed_systems": ["fixed_asset", "inventory"], + "dev_local": True, + "iat": now, + "exp": now + timedelta(hours=8), + } + token = jose_jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256") + return {"access_token": token, "token_type": "bearer"} + + +@router.get("/my-companies") +async def get_my_companies( + current_user: dict = Depends(get_current_user), +): + """ + Retorna las compañías accesibles para el usuario actual. + STUB: implementa con tu modelo de compañías. + En dev-local retorna una compañía ficticia para que el dashboard funcione. + """ + from core.config import settings + + if settings.DEV_LOCAL_AUTH and current_user.get("dev_local"): + return [{ + "id": settings.DEV_LOCAL_AUTH_COMPANY_ID, + "name": "Empresa Dev Local", + "tenant_id": settings.DEV_LOCAL_AUTH_TENANT_ID, + "is_active": True, + }] + + # Implementa aquí la consulta real a tu tabla de compañías. + return [] diff --git a/backend/api/v1/modules/core/auth/service.py b/backend/api/v1/modules/core/auth/service.py new file mode 100644 index 0000000..524db10 --- /dev/null +++ b/backend/api/v1/modules/core/auth/service.py @@ -0,0 +1,847 @@ +import logging +import httpx +from typing import Any, Dict, Optional +from jose import JWTError, jwt + +from core.config import settings +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from .dto import ( + LoginRequestDTO, + LogoutRequestDTO, + RefreshTokenRequestDTO, + TokenResponseDTO, + UserInfoResponseDTO, +) + +logger = logging.getLogger(__name__) + + +class AuthService: + """Servicio de autenticación centralizado vía Hub""" + + def __init__(self, db: Session): + self.db = db + + @staticmethod + def _clean_text(value: Any) -> Optional[str]: + if isinstance(value, str): + cleaned = value.strip() + if cleaned: + return cleaned + return None + + def _pick_text(self, *candidates: Any) -> Optional[str]: + for candidate in candidates: + value = self._clean_text(candidate) + if value: + return value + return None + + def _decode_kc_user_from_token(self, access_token: str) -> Dict[str, Any]: + try: + claims = jwt.get_unverified_claims(access_token) + return claims if isinstance(claims, dict) else {} + except JWTError: + return {} + except Exception: + return {} + + async def _get_kc_admin_user(self, keycloak_user_id: Optional[str]) -> Optional[Dict[str, Any]]: + """ + Fallback de datos de usuario consultando el Hub admin API. + Es opcional y no debe romper /me si falla. + """ + if not keycloak_user_id: + return None + if not settings.HUB_ADMIN_EMAIL or not settings.HUB_ADMIN_PASSWORD: + return None + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + login_resp = await client.post( + f"{settings.HUB_URL}api/v1/auth/login", + json={ + "username": settings.HUB_ADMIN_EMAIL, + "password": settings.HUB_ADMIN_PASSWORD, + }, + ) + if login_resp.status_code != 200: + return None + + admin_token = login_resp.json().get("access_token") + if not admin_token: + return None + + user_resp = await client.get( + f"{settings.HUB_URL}api/v1/hub/admins/{keycloak_user_id}", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + if user_resp.status_code == 200: + payload = user_resp.json() + return payload if isinstance(payload, dict) else None + except Exception as exc: + logger.debug("kc_admin_user_lookup_failed: %s", exc) + + return None + + def _extract_avatar_url(self, *sources: Any) -> Optional[str]: + for source in sources: + if not isinstance(source, dict): + continue + + direct = self._pick_text( + source.get("avatar_url"), + source.get("avatarUrl"), + source.get("picture"), + source.get("photo"), + ) + if direct: + return direct + + attrs = source.get("attributes") + if isinstance(attrs, dict): + attr_candidate = attrs.get("avatar_url") + if isinstance(attr_candidate, list) and attr_candidate: + value = self._clean_text(attr_candidate[0]) + if value: + return value + if isinstance(attr_candidate, str): + value = self._clean_text(attr_candidate) + if value: + return value + + return None + + async def login( + self, + login_data: LoginRequestDTO, + ip_address: str = None, + user_agent: str = None + ): + """ + Autentica usuario a través del Hub y obtiene tokens. + """ + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post( + f"{settings.HUB_URL}api/v1/auth/login", + json=login_data.model_dump() + ) + + if response.status_code == 200: + data = response.json() + + # Si el Hub devolvió una lista de tenants (hubo login exitoso pero falta seleccionar tenant) + if "tenants" in data: + from .dto import LoginChoiceResponseDTO, TenantInfoDTO + return LoginChoiceResponseDTO( + tenants=[TenantInfoDTO(**t) for t in data["tenants"]] + ) + + # Si devolvió tokens — lazy-link: verificar si hay invite pendiente + try: + await self._link_pending_invite(login_data.username) + except Exception as exc: + logger.warning("Lazy-link invite check failed (non-blocking): %s", exc) + + # Backfill: crear UserCompanyRole faltantes para usuarios ya registrados + try: + login_sub = data.get("sub") or data.get("user_id") + if not login_sub: + claims = self._decode_kc_user_from_token(data.get("access_token", "")) + login_sub = claims.get("sub") + self._backfill_company_roles(login_sub) + except Exception as exc: + logger.warning("backfill_company_roles failed on login (non-blocking): %s", exc) + + # Sync de perfil/avatar desde Workspace usando el mismo bearer. + # No bloquea login si Workspace no responde. + access_token = data.get("access_token") + if access_token: + from core.workspace_profile_sync import sync_workspace_profile_for_user + from core.workspace_profile_client import WorkspaceProfileClient + + workspace_profile = None + try: + workspace_profile = await WorkspaceProfileClient().get_me(access_token) + except Exception as exc: + logger.warning( + "workspace_profile_sync_failed", + extra={ + "event": "workspace_profile_sync_failed", + "phase": "login", + "error": str(exc), + }, + ) + workspace_profile = None + + await sync_workspace_profile_for_user( + self.db, + access_token=access_token, + keycloak_user_id=(workspace_profile or {}).get("sub") + or data.get("sub") + or data.get("user_id"), + tenant_id=data.get("tenant_id"), + workspace_profile=workspace_profile, + force=True, + ) + + # AUDIT LOG: implementa tu servicio de auditoría aquí si lo necesitas. + + return TokenResponseDTO(**data) + + # Pasar el mensaje de error real del Hub al cliente + try: + hub_detail = response.json().get("detail", None) + except Exception: + hub_detail = None + + if response.status_code == 401: + raise HTTPException(status_code=401, detail=hub_detail or "Credenciales inválidas") + + logger.error(f"Hub login failed with status {response.status_code}: {response.text}") + raise HTTPException(status_code=response.status_code, detail=hub_detail or "Error en el servidor de autenticación") + + except httpx.HTTPError as e: + logger.error(f"Hub unreachable during login: {str(e)}") + raise HTTPException(status_code=503, detail="Authentication service unavailable") + except HTTPException: + raise + except Exception as e: + logger.error(f"Unexpected login error: {str(e)}") + raise HTTPException(status_code=500, detail="Authentication error") + + async def refresh_token(self, refresh_data: RefreshTokenRequestDTO) -> TokenResponseDTO: + """ + Refresca el access token usando el Hub + """ + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post( + f"{settings.HUB_URL}api/v1/auth/refresh", + json=refresh_data.model_dump() + ) + + if response.status_code == 200: + data = response.json() + from core.workspace_profile_sync import sync_workspace_profile_for_user + from core.workspace_profile_client import WorkspaceProfileClient + + workspace_profile = None + try: + workspace_profile = await WorkspaceProfileClient().get_me( + data.get("access_token", "") + ) + except Exception as exc: + logger.warning( + "workspace_profile_sync_failed", + extra={ + "event": "workspace_profile_sync_failed", + "phase": "refresh", + "error": str(exc), + }, + ) + workspace_profile = None + + await sync_workspace_profile_for_user( + self.db, + access_token=data.get("access_token"), + keycloak_user_id=(workspace_profile or {}).get("sub") + or data.get("sub") + or data.get("user_id"), + tenant_id=data.get("tenant_id"), + workspace_profile=workspace_profile, + force=True, + ) + return TokenResponseDTO(**data) + + raise HTTPException(status_code=401, detail="Invalid or expired refresh token") + + except Exception as e: + logger.error(f"Token refresh error: {str(e)}") + raise HTTPException(status_code=500, detail="Token refresh error") + + async def get_user_info(self, access_token: str) -> UserInfoResponseDTO: + """ + Obtiene información del usuario desde el Hub + """ + from core.security import verify_token + from core.workspace_profile_sync import sync_workspace_profile_for_user + # Aprovechamos la verificación (y cache) de security.py + user_info = await verify_token(access_token) + + kc_user = self._decode_kc_user_from_token(access_token) + keycloak_user_id = self._pick_text(user_info.get("sub"), kc_user.get("sub")) + + needs_admin_fallback = any( + not self._clean_text(user_info.get(field)) + for field in ("email", "preferred_username") + ) or self._extract_avatar_url(user_info) is None + + kc_admin_user = None + if needs_admin_fallback: + kc_admin_user = await self._get_kc_admin_user(keycloak_user_id) + + first_name = self._pick_text( + user_info.get("first_name"), + user_info.get("given_name"), + kc_user.get("given_name"), + kc_user.get("first_name"), + (kc_admin_user or {}).get("firstName"), + (kc_admin_user or {}).get("first_name"), + ) + last_name = self._pick_text( + user_info.get("last_name"), + user_info.get("family_name"), + kc_user.get("family_name"), + kc_user.get("last_name"), + (kc_admin_user or {}).get("lastName"), + (kc_admin_user or {}).get("last_name"), + ) + full_name = self._pick_text( + f"{first_name} {last_name}" if first_name and last_name else None, + first_name, + last_name, + ) + + enriched_user_info = dict(user_info) + enriched_user_info["sub"] = keycloak_user_id or user_info.get("sub") + enriched_user_info["email"] = self._pick_text( + user_info.get("email"), + (kc_admin_user or {}).get("email"), + kc_user.get("email"), + ) + enriched_user_info["preferred_username"] = self._pick_text( + user_info.get("preferred_username"), + user_info.get("username"), + kc_user.get("preferred_username"), + kc_user.get("username"), + (kc_admin_user or {}).get("username"), + ) + enriched_user_info["avatar_url"] = self._extract_avatar_url( + user_info, + kc_user, + kc_admin_user or {}, + ) + enriched_user_info["name"] = self._pick_text( + user_info.get("name"), + full_name, + kc_user.get("name"), + enriched_user_info.get("preferred_username"), + ) + + await sync_workspace_profile_for_user( + self.db, + access_token=access_token, + keycloak_user_id=enriched_user_info.get("sub"), + tenant_id=enriched_user_info.get("tenant_id"), + workspace_profile=enriched_user_info, + ) + return UserInfoResponseDTO(**enriched_user_info) + + async def logout(self, logout_data: LogoutRequestDTO) -> dict: + """ + Cierra sesión a través del Hub + """ + try: + async with httpx.AsyncClient(timeout=10.0) as client: + await client.post( + f"{settings.HUB_URL}api/v1/auth/logout", + json=logout_data.model_dump() + ) + return {"message": "Logged out successfully"} + except Exception as e: + logger.error(f"Logout error: {str(e)}") + return {"message": "Logged out"} + + async def register(self, register_data: Any) -> Any: + """ + Registra un usuario. + - Si trae invite_token: valida el token local, crea usuario en Hub y + genera la fila UserTenant local, luego consume el token. + - Si no trae invite_token: reenvía directamente al Hub (flujo original). + """ + if getattr(register_data, "invite_token", None): + return await self._register_with_invite(register_data) + + # Flujo original — reenviar al Hub sin invite_token + try: + payload = register_data.model_dump(exclude={"invite_token"}) + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post( + f"{settings.HUB_URL}api/v1/auth/register", + json=payload, + ) + if response.status_code == 201: + return response.json() + raise HTTPException(status_code=response.status_code, detail=response.text) + except HTTPException: + raise + except Exception as e: + logger.error(f"Registration error: {str(e)}") + raise HTTPException(status_code=500, detail="Registration error") + + async def _register_with_invite(self, register_data: Any) -> Any: + """Flujo de registro con token de invitación local.""" + from api.v1.modules.core.invites.service import InviteService + from api.v1.modules.core.tenants.models import Tenant + from api.v1.modules.core.user_tenant.models import UserTenant + + invite_service = InviteService(self.db) + + # 1. Validar invite token (sin consumir) + invite_result = invite_service.validate( + register_data.invite_token, + register_data.tenant_slug, + str(register_data.email), + ) + + # 2. Buscar tenant local + tenant = ( + self.db.query(Tenant) + .filter(Tenant.slug == register_data.tenant_slug) + .first() + ) + if not tenant: + raise HTTPException(status_code=404, detail="Tenant no encontrado") + + # 3. Obtener token de service account y gestionar usuario en Hub + hub_user_id = None + try: + async with httpx.AsyncClient(timeout=15.0) as client: + # Login con service account + login_resp = await client.post( + f"{settings.HUB_URL}api/v1/auth/login", + json={ + "username": settings.HUB_ADMIN_EMAIL, + "password": settings.HUB_ADMIN_PASSWORD, + }, + ) + if login_resp.status_code != 200: + raise HTTPException(status_code=503, detail="No se pudo autenticar con el sistema de autenticación") + svc_token = login_resp.json().get("access_token", "") + + # Verificar si el usuario ya existe en el Hub + search_resp = await client.get( + f"{settings.HUB_URL}api/v1/hub/admins", + params={"email": str(register_data.email)}, + headers={"Authorization": f"Bearer {svc_token}"}, + ) + existing_user = None + if search_resp.status_code == 200: + admins = search_resp.json() + items = admins if isinstance(admins, list) else admins.get("items", []) + matches = [a for a in items if a.get("email", "").lower() == str(register_data.email).lower()] + if matches: + existing_user = matches[0] + + if existing_user: + # Usuario ya existe — solo vinculamos (no creamos nuevo) + hub_user_id = existing_user.get("id") + else: + # Crear usuario via admin endpoint (no requiere invite_token) + hub_payload = { + "username": register_data.username, + "email": str(register_data.email), + "password": register_data.password, + "first_name": register_data.first_name, + "last_name": register_data.last_name, + "tenant_slug": register_data.tenant_slug, + } + create_resp = await client.post( + f"{settings.HUB_URL}api/v1/hub/admins", + json=hub_payload, + headers={"Authorization": f"Bearer {svc_token}"}, + ) + if create_resp.status_code in (200, 201): + hub_user_id = create_resp.json().get("id") + else: + try: + detail = create_resp.json().get("detail", create_resp.text) + except Exception: + detail = create_resp.text + raise HTTPException(status_code=create_resp.status_code, detail=detail) + + except HTTPException: + raise + except Exception as exc: + logger.error("Hub admin create error during invite flow: %s", exc) + raise HTTPException(status_code=503, detail="Error al crear usuario en el sistema de autenticación") + + # 4. Crear fila UserTenant y UserCompanyRole local + if hub_user_id and invite_result.company_id: + try: + ut = UserTenant( + keycloak_user_id=hub_user_id, + tenant_id=tenant.id, + company_id=invite_result.company_id, + role=invite_result.role, + is_active=True, + first_name=register_data.first_name, + last_name=register_data.last_name, + ) + self.db.add(ut) + self.db.flush() + except Exception as exc: + logger.warning("Could not create UserTenant (may already exist): %s", exc) + self.db.rollback() + + # Asignar UserCompanyRole para que el usuario tenga permisos resueltos + try: + from api.v1.modules.core.permissions.models import CompanyRole, UserCompanyRole + company_role_obj = ( + self.db.query(CompanyRole) + .filter( + CompanyRole.code == invite_result.role, + CompanyRole.company_id == invite_result.company_id, + CompanyRole.is_active == True, + ) + .first() + ) + if company_role_obj: + existing_ucr = ( + self.db.query(UserCompanyRole) + .filter( + UserCompanyRole.user_id == hub_user_id, + UserCompanyRole.company_role_id == company_role_obj.id, + UserCompanyRole.company_id == invite_result.company_id, + ) + .first() + ) + if not existing_ucr: + ucr = UserCompanyRole( + user_id=hub_user_id, + company_role_id=company_role_obj.id, + company_id=invite_result.company_id, + tenant_id=tenant.id, + is_active=True, + ) + self.db.add(ucr) + self.db.commit() + except Exception as exc: + logger.warning("Could not create UserCompanyRole for invited user: %s", exc) + self.db.rollback() + + # 5. Consumir invite token + invite_service.consume_by_id(invite_result.invite_id) + + return { + "user_id": hub_user_id or "", + "username": register_data.username, + "email": str(register_data.email), + "message": "Usuario registrado exitosamente", + } + + async def exchange_code(self, exchange_data: Any) -> TokenResponseDTO: + """ + Intercambia código por tokens a través del Hub + """ + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post( + f"{settings.HUB_URL}api/v1/auth/exchange-code", + json=exchange_data.model_dump() + ) + if response.status_code == 200: + data = response.json() + # Lazy-link: crear UserTenant si hay invite pendiente + try: + await self._link_pending_invite("", access_token=data.get("access_token", "")) + except Exception as exc: + logger.warning("exchange_code lazy-link failed (non-blocking): %s", exc) + # Backfill: crear UserCompanyRole faltantes para usuarios ya registrados + try: + ec_claims = self._decode_kc_user_from_token(data.get("access_token", "")) + self._backfill_company_roles(ec_claims.get("sub") or data.get("sub")) + except Exception as exc: + logger.warning("backfill_company_roles failed on exchange_code (non-blocking): %s", exc) + return TokenResponseDTO(**data) + raise HTTPException(status_code=response.status_code, detail="Code exchange failed") + except Exception as e: + logger.error(f"Exchange code error: {str(e)}") + raise HTTPException(status_code=500, detail="Exchange code error") + + async def switch_tenant(self, **kwargs) -> TokenResponseDTO: + """ + Cambia de tenant a través del Hub + """ + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post( + f"{settings.HUB_URL}api/v1/auth/switch-tenant", + json=kwargs + ) + if response.status_code == 200: + return TokenResponseDTO(**response.json()) + raise HTTPException(status_code=response.status_code, detail="Switch tenant failed") + except Exception as e: + logger.error(f"Switch tenant error: {str(e)}") + raise HTTPException(status_code=500, detail="Switch tenant error") + + async def sso_exchange(self, relay_token: str) -> TokenResponseDTO: + """ + Canjea un relay token de un solo uso por KC tokens. + Llama al Hub backend (server-to-server), sin Bearer requerido en el Hub. + """ + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post( + f"{settings.HUB_URL}api/v1/auth/sso-exchange", + json={"relay_token": relay_token}, + ) + if response.status_code == 200: + data = response.json() + # Lazy-link: crear UserTenant si hay invite pendiente (usuario registrado vía workspace) + try: + await self._link_pending_invite("", access_token=data.get("access_token", "")) + except Exception as exc: + logger.warning("sso_exchange lazy-link failed (non-blocking): %s", exc) + # Backfill: crear UserCompanyRole faltantes para usuarios ya registrados + try: + sso_claims = self._decode_kc_user_from_token(data.get("access_token", "")) + self._backfill_company_roles(sso_claims.get("sub") or data.get("sub")) + except Exception as exc: + logger.warning("backfill_company_roles failed on sso_exchange (non-blocking): %s", exc) + return TokenResponseDTO( + access_token=data["access_token"], + refresh_token=data["refresh_token"], + token_type=data.get("token_type", "bearer"), + expires_in=data.get("expires_in", 3600), + tenant_id=data.get("tenant_id"), + tenant_slug=data.get("tenant_slug"), + ) + raise HTTPException( + status_code=response.status_code, + detail=response.json().get("detail", "SSO exchange failed"), + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"SSO exchange error: {str(e)}") + raise HTTPException(status_code=500, detail="SSO exchange error") + + async def _link_pending_invite(self, username_or_email: str, access_token: str = None) -> None: + """ + Lazy-link: después de un login exitoso comprueba si existe un invite_token + pendiente para el email del usuario. Si lo hay, crea la fila UserTenant + y consume el token. + + Si se provee access_token, extrae hub_user_id y email directamente del JWT + sin necesidad de un lookup extra al Hub. + """ + from datetime import datetime, timezone + from api.v1.modules.core.invites.models import InviteToken + from api.v1.modules.core.tenants.models import Tenant + from api.v1.modules.core.user_tenant.models import UserTenant + + hub_user_id = None + user_email = username_or_email + + # Si tenemos el access_token, extraer info del JWT directamente (sin red) + if access_token: + try: + claims = self._decode_kc_user_from_token(access_token) + hub_user_id = claims.get("sub") + user_email = claims.get("email") or username_or_email + except Exception as exc: + logger.debug("_link_pending_invite: JWT decode failed: %s", exc) + + # Sin access_token: buscar usuario en el Hub vía service account + if not hub_user_id: + if not user_email: + return # Sin email ni hub_user_id no podemos buscar el invite + try: + async with httpx.AsyncClient(timeout=10.0) as client: + login_resp = await client.post( + f"{settings.HUB_URL}api/v1/auth/login", + json={"username": settings.HUB_ADMIN_EMAIL, "password": settings.HUB_ADMIN_PASSWORD}, + ) + if login_resp.status_code != 200: + return + svc_token = login_resp.json().get("access_token", "") + search_resp = await client.get( + f"{settings.HUB_URL}api/v1/hub/admins", + params={"email": username_or_email}, + headers={"Authorization": f"Bearer {svc_token}"}, + ) + if search_resp.status_code == 200: + items = search_resp.json() + items = items if isinstance(items, list) else items.get("items", []) + matches = [ + u for u in items + if u.get("email", "").lower() == username_or_email.lower() + or u.get("username", "").lower() == username_or_email.lower() + ] + if matches: + hub_user_id = matches[0].get("id") + user_email = matches[0].get("email", username_or_email) + if not hub_user_id: + return + except Exception as exc: + logger.debug("_link_pending_invite: hub lookup failed: %s", exc) + return + + now = datetime.now(timezone.utc) + pending = ( + self.db.query(InviteToken) + .filter( + InviteToken.email == user_email, + InviteToken.used_at.is_(None), + InviteToken.expires_at > now, + ) + .first() + ) + if not pending: + return + + tenant = ( + self.db.query(Tenant) + .filter(Tenant.slug == pending.tenant_slug) + .first() + ) + if not tenant: + logger.warning("_link_pending_invite: tenant %s not found", pending.tenant_slug) + return + + # Evitar duplicados + existing = ( + self.db.query(UserTenant) + .filter( + UserTenant.keycloak_user_id == hub_user_id, + UserTenant.tenant_id == tenant.id, + ) + .first() + ) + if existing: + # Vincular existe, solo consumir el token + pending.used_at = now + self.db.commit() + return + + try: + ut = UserTenant( + keycloak_user_id=hub_user_id, + tenant_id=tenant.id, + company_id=pending.company_id, + role=pending.role, + is_active=True, + ) + self.db.add(ut) + self.db.flush() + logger.info( + "Lazy-link: UserTenant created for user=%s tenant=%s role=%s", + hub_user_id, + tenant.slug, + pending.role, + ) + except Exception as exc: + logger.warning("_link_pending_invite: could not create UserTenant: %s", exc) + self.db.rollback() + + # Asignar UserCompanyRole para que el usuario tenga permisos resueltos + if pending.company_id: + try: + from api.v1.modules.core.permissions.models import CompanyRole, UserCompanyRole + company_role_obj = ( + self.db.query(CompanyRole) + .filter( + CompanyRole.code == pending.role, + CompanyRole.company_id == pending.company_id, + CompanyRole.is_active == True, + ) + .first() + ) + if company_role_obj: + existing_ucr = ( + self.db.query(UserCompanyRole) + .filter( + UserCompanyRole.user_id == hub_user_id, + UserCompanyRole.company_role_id == company_role_obj.id, + UserCompanyRole.company_id == pending.company_id, + ) + .first() + ) + if not existing_ucr: + ucr = UserCompanyRole( + user_id=hub_user_id, + company_role_id=company_role_obj.id, + company_id=pending.company_id, + tenant_id=tenant.id, + is_active=True, + ) + self.db.add(ucr) + except Exception as exc: + logger.warning("_link_pending_invite: could not create UserCompanyRole: %s", exc) + + pending.used_at = now + self.db.commit() + + def _backfill_company_roles(self, hub_user_id: str) -> None: + """ + Self-healing: para usuarios ya registrados vía invitación que tienen UserTenant + pero no UserCompanyRole (creados antes del fix del flujo de invitación). + Por cada UserTenant activo con role y company_id busca el CompanyRole y crea + el UserCompanyRole si no existe. Non-blocking. + """ + if not hub_user_id: + return + try: + from api.v1.modules.core.user_tenant.models import UserTenant + from api.v1.modules.core.permissions.models import CompanyRole, UserCompanyRole + + user_tenants = ( + self.db.query(UserTenant) + .filter( + UserTenant.keycloak_user_id == hub_user_id, + UserTenant.is_active == True, + UserTenant.company_id.isnot(None), + UserTenant.role.isnot(None), + ) + .all() + ) + + changed = False + for ut in user_tenants: + company_role_obj = ( + self.db.query(CompanyRole) + .filter( + CompanyRole.code == ut.role, + CompanyRole.company_id == ut.company_id, + CompanyRole.is_active == True, + ) + .first() + ) + if not company_role_obj: + continue + + existing = ( + self.db.query(UserCompanyRole) + .filter( + UserCompanyRole.user_id == hub_user_id, + UserCompanyRole.company_role_id == company_role_obj.id, + UserCompanyRole.company_id == ut.company_id, + ) + .first() + ) + if not existing: + self.db.add(UserCompanyRole( + user_id=hub_user_id, + company_role_id=company_role_obj.id, + company_id=ut.company_id, + tenant_id=ut.tenant_id, + is_active=True, + )) + changed = True + logger.info( + "backfill: UserCompanyRole created for user=%s company=%s role=%s", + hub_user_id, ut.company_id, ut.role, + ) + + if changed: + self.db.commit() + except Exception as exc: + logger.warning("_backfill_company_roles failed (non-blocking): %s", exc) + self.db.rollback() + diff --git a/backend/api/v1/modules/core/dashboard/__init__.py b/backend/api/v1/modules/core/dashboard/__init__.py new file mode 100644 index 0000000..2bf06e7 --- /dev/null +++ b/backend/api/v1/modules/core/dashboard/__init__.py @@ -0,0 +1,7 @@ +""" +Módulo de dashboard para estadísticas y métricas empresariales +""" + +from .routes import router + +__all__ = ["router"] diff --git a/backend/api/v1/modules/core/dashboard/dto.py b/backend/api/v1/modules/core/dashboard/dto.py new file mode 100644 index 0000000..1228c98 --- /dev/null +++ b/backend/api/v1/modules/core/dashboard/dto.py @@ -0,0 +1,109 @@ +""" +DTOs para el dashboard de estadísticas y métricas empresariales +""" + +from typing import Dict, List, Optional +from pydantic import BaseModel, Field +from datetime import datetime + + +class KPIMetric(BaseModel): + """Métrica individual de KPI""" + + label: str = Field(..., description="Nombre del indicador") + value: int | float = Field(..., description="Valor actual") + previous_value: Optional[int | float] = Field( + None, description="Valor anterior para comparación" + ) + percentage_change: Optional[float] = Field(None, description="Porcentaje de cambio") + trend: Optional[str] = Field(None, description="up, down, stable") + unit: Optional[str] = Field(None, description="Unidad de medida (%, USD, etc)") + + +class ActivityItem(BaseModel): + """Item de actividad reciente""" + + id: int + type: str = Field( + ..., description="Tipo de actividad: invoice, pedimento, client, etc" + ) + title: str = Field(..., description="Título descriptivo") + description: Optional[str] = Field(None, description="Descripción adicional") + timestamp: datetime + status: Optional[str] = Field(None, description="Estado del item") + icon: Optional[str] = Field(None, description="Icono a mostrar") + + +class ChartDataPoint(BaseModel): + """Punto de datos para gráficas""" + + label: str + value: float + category: Optional[str] = None + + +class DashboardStats(BaseModel): + """Estadísticas generales del dashboard""" + + # KPIs principales + total_invoices: KPIMetric + total_pedimentos: KPIMetric + total_clients: KPIMetric + total_providers: KPIMetric + active_items: KPIMetric + pending_approvals: KPIMetric + + # Estadísticas financieras + total_value_imports: Optional[float] = Field( + None, description="Valor total de importaciones" + ) + total_value_exports: Optional[float] = Field( + None, description="Valor total de exportaciones" + ) + + # Datos para gráficas + invoices_by_month: List[ChartDataPoint] = Field(default_factory=list) + pedimentos_by_month: List[ChartDataPoint] = Field(default_factory=list) + operations_by_type: List[ChartDataPoint] = Field(default_factory=list) + top_clients: List[ChartDataPoint] = Field(default_factory=list) + top_providers: List[ChartDataPoint] = Field(default_factory=list) + + # Actividad reciente + recent_activity: List[ActivityItem] = Field(default_factory=list) + + # Metadata + generated_at: datetime = Field(default_factory=datetime.utcnow) + company_id: int + company_name: Optional[str] = None + + +class OperationsOverview(BaseModel): + """Vista general de operaciones""" + + total_operations: int + by_type: Dict[str, int] = Field(default_factory=dict) + by_status: Dict[str, int] = Field(default_factory=dict) + avg_processing_time: Optional[float] = Field( + None, description="Tiempo promedio en días" + ) + + +class InventoryMetrics(BaseModel): + """Métricas de inventario""" + + total_items: int + items_in_stock: int + items_low_stock: int + total_value: Optional[float] = None + by_category: Dict[str, int] = Field(default_factory=dict) + + +class ComplianceMetrics(BaseModel): + """Métricas de cumplimiento normativo""" + + pending_documents: int + expired_permits: int + upcoming_deadlines: int + compliance_score: Optional[float] = Field( + None, description="Score de cumplimiento 0-100" + ) diff --git a/backend/api/v1/modules/core/dashboard/routes.py b/backend/api/v1/modules/core/dashboard/routes.py new file mode 100644 index 0000000..6e48e78 --- /dev/null +++ b/backend/api/v1/modules/core/dashboard/routes.py @@ -0,0 +1,84 @@ +""" +Endpoints del dashboard para estadísticas y métricas empresariales +""" + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from core.database import get_core_db +from core.security import get_current_user, validate_access_to_resource + +from .dto import DashboardStats, OperationsOverview, InventoryMetrics +from .service import DashboardService + +router = APIRouter(prefix="/dashboard", tags=["Dashboard"]) + + +@router.get("/stats", response_model=DashboardStats) +async def get_dashboard_stats( + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Obtiene estadísticas completas del dashboard para la compañía especificada. + + Incluye: + - KPIs principales (facturas, pedimentos, clientes, proveedores, items) + - Gráficas de tendencias (facturas por mes, operaciones por tipo) + - Top clientes y proveedores + - Actividad reciente + """ + # Validar acceso + tenant_id = validate_access_to_resource(db, company_id, current_user) + + # Generar estadísticas + service = DashboardService(db, tenant_id, company_id) + stats = service.get_complete_dashboard_stats() + + return stats + + +@router.get("/operations-overview", response_model=OperationsOverview) +async def get_operations_overview( + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Obtiene una vista general de las operaciones + """ + tenant_id = validate_access_to_resource(db, company_id, current_user) + + service = DashboardService(db, tenant_id, company_id) + + # Implementación básica + ops_by_type = service.get_operations_by_type() + + return OperationsOverview( + total_operations=sum(int(op.value) for op in ops_by_type), + by_type={op.label: int(op.value) for op in ops_by_type}, + by_status={}, + ) + + +@router.get("/inventory-metrics", response_model=InventoryMetrics) +async def get_inventory_metrics( + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Obtiene métricas de inventario + """ + tenant_id = validate_access_to_resource(db, company_id, current_user) + + service = DashboardService(db, tenant_id, company_id) + items_kpi = service.get_items_metrics() + + return InventoryMetrics( + total_items=int(items_kpi.value), + items_in_stock=int(items_kpi.value), # Simplificado + items_low_stock=0, + by_category={}, + ) diff --git a/backend/api/v1/modules/core/dashboard/service.py b/backend/api/v1/modules/core/dashboard/service.py new file mode 100644 index 0000000..5aa91c8 --- /dev/null +++ b/backend/api/v1/modules/core/dashboard/service.py @@ -0,0 +1,43 @@ +""" +Servicio del dashboard — STUB. +Implementa las métricas de tu proyecto aquí. +""" + +from sqlalchemy.orm import Session + +from .dto import DashboardStats, KPIMetric, OperationsOverview, InventoryMetrics + + +class DashboardService: + """Stub — reemplaza con las consultas de tu proyecto.""" + + def __init__(self, db: Session, tenant_id: int, company_id: int): + self.db = db + self.tenant_id = tenant_id + self.company_id = company_id + + def get_stats(self) -> DashboardStats: + empty_kpi = KPIMetric(label="", value=0, trend="stable") + return DashboardStats( + company_id=self.company_id, + generated_at="", + total_invoices=empty_kpi, + total_pedimentos=empty_kpi, + total_clients=empty_kpi, + total_providers=empty_kpi, + active_items=empty_kpi, + pending_approvals=empty_kpi, + invoices_by_month=[], + operations_by_type=[], + top_clients=[], + top_providers=[], + recent_activity=[], + ) + + def get_operations_overview(self) -> OperationsOverview: + return OperationsOverview(total_operations=0, by_type={}, by_status={}) + + def get_inventory_metrics(self) -> InventoryMetrics: + return InventoryMetrics( + total_items=0, items_in_stock=0, items_low_stock=0, by_category={} + ) diff --git a/backend/api/v1/modules/core/help_center/models.py b/backend/api/v1/modules/core/help_center/models.py new file mode 100644 index 0000000..e7df432 --- /dev/null +++ b/backend/api/v1/modules/core/help_center/models.py @@ -0,0 +1,32 @@ +import uuid +from datetime import datetime, timezone +from sqlalchemy import Column, String, Text, DateTime, Integer +from sqlalchemy.dialects.postgresql import UUID +from core.database import Base + +class HelpArticle(Base): + """ + Modelo para los artículos de ayuda (Base de Conocimientos). + Sincronizado entre Servidor Central y Clientes. + """ + __tablename__ = "help_articles" + + uuid = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True) + slug = Column(String(255), unique=True, index=True, nullable=False) + title = Column(String(255), nullable=False) + content = Column(Text, nullable=False) + updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False) + last_editor = Column(String(255), nullable=False) + + # Library Mode Fields + category = Column(String(255), nullable=True, default="General") + order = Column(Integer, nullable=True, default=0) + + # Removed missing fields to avoid 500 errors (No migration approach) + # content_type = Column(String(50), nullable=False, default="article") + # file_url = Column(String(512), nullable=True) + # file_size = Column(Integer, nullable=True) + # mime_type = Column(String(100), nullable=True) + + def __repr__(self): + return f"" diff --git a/backend/api/v1/modules/core/help_center/routes.py b/backend/api/v1/modules/core/help_center/routes.py new file mode 100644 index 0000000..c76d697 --- /dev/null +++ b/backend/api/v1/modules/core/help_center/routes.py @@ -0,0 +1,273 @@ +import mimetypes +import os +import shutil +import uuid +from datetime import datetime +from typing import List, Optional, Dict, Any +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Header, status, UploadFile, File +from fastapi.responses import Response +from sqlalchemy.orm import Session + +from core.config import settings +from core.database import get_core_db +from core.s3_keys import ( + help_asset_key, + help_public_api_path, + help_s3_key_to_public_relative_path, + system_help_object_key, +) +from core.storage_s3 import get_object_bytes, put_object_bytes +from core.security import get_current_user, has_role +from .schemas import HelpArticleInDB, HelpArticleCreate, HelpArticleUpdate, HelpSyncRequest, HelpSyncResponse +from .services import HelpCenterService +from .tasks import sync_single_article_task + +router = APIRouter(prefix="/help-center", tags=["Help Center"]) + +def verify_sync_token(x_sync_token: str = Header(...)): + if x_sync_token != settings.SYNC_SECRET_TOKEN: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid Sync Token" + ) + +def trigger_sync_or_broadcast(article_uuid: UUID): + """ + Helper function to handle synchronization logic. + - If we are a Client (CENTRAL_SERVER_URL is set): Trigger upstream sync. + - If we are the Hub (No CENTRAL_SERVER, but SPOKE_URLS set): Trigger broadcast. + """ + import logging + logger = logging.getLogger(__name__) + + try: + logger.info(f"DEBUG: Triggering sync/broadcast for article {article_uuid}") + logger.debug(f"DEBUG: CENTRAL_SERVER_URL='{settings.CENTRAL_SERVER_URL}' SPOKE_URLS='{settings.SPOKE_URLS}'") + + # 1. Upstream Sync (Client -> Hub) + if settings.CENTRAL_SERVER_URL and settings.CENTRAL_SERVER_URL != '""': + logger.info(f"DEBUG: Queueing sync_single_article_task for {article_uuid}") + sync_single_article_task.delay(str(article_uuid)) + + # 2. Downstream Broadcast (Hub -> Spokes) + # Only if we are the Hub (no upstream) and have spokes configured. + elif (not settings.CENTRAL_SERVER_URL or settings.CENTRAL_SERVER_URL == '""') and settings.SPOKE_URLS: + from .tasks import broadcast_help_update + logger.info(f"DEBUG: Queueing broadcast_help_update for {article_uuid}") + # origin_client_uuid is None because this change originated on the Hub itself + broadcast_help_update.delay(str(article_uuid), None) + else: + logger.info(f"DEBUG: No sync/broadcast needed for {article_uuid} (Config empty or Hub mode without spokes)") + + except Exception as e: + logger.error(f"ERROR in trigger_sync_or_broadcast for article {article_uuid}: {str(e)}", exc_info=True) + # We don't re-raise here to avoid returning 500 to the user if the save was successful + +@router.post("/sync/", response_model=HelpSyncResponse, dependencies=[Depends(verify_sync_token)]) +def sync_help_article(sync_data: HelpSyncRequest, db: Session = Depends(get_core_db)): + """ + Endpoint de sincronización inteligente para artículos de ayuda. + Requiere X-Sync-Token en los headers. + """ + result = HelpCenterService.sync_article(db, sync_data) + + # Broadcast to other spokes (Hub logic) + import logging + logger = logging.getLogger(__name__) + logger.info(f"DEBUG: Hub Sync Check. CENTRAL_SERVER_URL='{settings.CENTRAL_SERVER_URL}' SPOKE_URLS='{settings.SPOKE_URLS}'") + + if not settings.CENTRAL_SERVER_URL and settings.SPOKE_URLS: + # We are the Hub (no central server to push to) and have Spokes configured + from .tasks import broadcast_help_update + logger.info(f"DEBUG: Triggering broadcast for article {sync_data.article_uuid}") + broadcast_help_update.delay( + str(sync_data.article_uuid), + str(sync_data.origin_client_uuid) if sync_data.origin_client_uuid else None + ) + else: + logger.info("DEBUG: Broadcast skipped (Condition failed)") + + return result + +@router.get("/files/{file_path:path}") +def serve_help_file(file_path: str): + """Sirve un objeto bajo system/help/ (público vía middleware).""" + if ".." in file_path or file_path.startswith("/"): + raise HTTPException(status_code=404, detail="Not found") + try: + key = system_help_object_key(file_path) + except ValueError: + raise HTTPException(status_code=404, detail="Not found") + if not settings.use_s3_object_storage: + legacy = os.path.join("uploads", "help", file_path) + if not os.path.isfile(legacy): + raise HTTPException(status_code=404, detail="Not found") + with open(legacy, "rb") as f: + data = f.read() + media = mimetypes.guess_type(file_path)[0] or "application/octet-stream" + return Response(content=data, media_type=media) + try: + data = get_object_bytes(key) + except Exception: + raise HTTPException(status_code=404, detail="Not found") + media = mimetypes.guess_type(file_path)[0] or "application/octet-stream" + return Response(content=data, media_type=media) + + +@router.post("/upload-image/") +async def upload_help_image( + file: UploadFile = File(...), + current_user: Dict[str, Any] = Depends(has_role("admin")) +): + """Sube una imagen para usar en los artículos.""" + try: + file_ext = os.path.splitext(file.filename or "")[1] or ".png" + new_filename = f"{uuid.uuid4()}{file_ext}" + body = await file.read() + if settings.use_s3_object_storage: + key = help_asset_key("", new_filename) + ct = mimetypes.guess_type(new_filename)[0] or "image/png" + put_object_bytes(key, body, content_type=ct) + rel = help_s3_key_to_public_relative_path(key) + return {"url": help_public_api_path(rel)} + os.makedirs("uploads/help", exist_ok=True) + file_location = f"uploads/help/{new_filename}" + with open(file_location, "wb") as f: + f.write(body) + return {"url": f"/api/uploads/help/{new_filename}"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.post("/upload-asset/") +async def upload_help_asset( + file: UploadFile = File(...), + current_user: Dict[str, Any] = Depends(has_role("admin")) +): + """Sube cualquier tipo de archivo (PDF, Video, etc.) para la biblioteca.""" + try: + file_ext = os.path.splitext(file.filename or "")[1].lower() + new_filename = f"{uuid.uuid4()}{file_ext}" + subfolder = "assets" + if file_ext == ".pdf": + subfolder = "pdfs" + elif file_ext in [".mp4", ".mov", ".avi"]: + subfolder = "videos" + + if settings.use_s3_object_storage: + body = await file.read() + key = help_asset_key(subfolder, new_filename) + ct = file.content_type or mimetypes.guess_type(new_filename)[0] or "application/octet-stream" + put_object_bytes(key, body, content_type=ct) + rel = help_s3_key_to_public_relative_path(key) + return { + "url": help_public_api_path(rel), + "filename": file.filename, + "size": len(body), + "mime_type": file.content_type, + } + + folder = f"uploads/help/{subfolder}" + os.makedirs(folder, exist_ok=True) + file_location = f"{folder}/{new_filename}" + body = await file.read() + with open(file_location, "wb") as f: + f.write(body) + file_size = os.path.getsize(file_location) + return { + "url": f"/api/{file_location}", + "filename": file.filename, + "size": file_size, + "mime_type": file.content_type, + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/articles/", response_model=List[HelpArticleInDB]) +def list_articles( + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user) +): + """Lista todos los artículos de ayuda.""" + return HelpCenterService.get_all(db) + +@router.get("/modifications/", response_model=List[HelpArticleInDB], dependencies=[Depends(verify_sync_token)]) +def get_modifications(since: datetime, db: Session = Depends(get_core_db)): + """Obtiene artículos modificados desde la fecha indicada (Polling). Requiere X-Sync-Token.""" + return HelpCenterService.get_modifications(db, since) + +@router.get("/articles/{article_uuid}/", response_model=HelpArticleInDB) +def get_article( + article_uuid: UUID, + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user) +): + """Obtiene un artículo por UUID.""" + article = HelpCenterService.get_by_uuid(db, article_uuid) + if not article: + raise HTTPException(status_code=404, detail="Article not found") + return article + +@router.post("/articles/", response_model=HelpArticleInDB, status_code=status.HTTP_201_CREATED) +def create_article( + article: HelpArticleCreate, + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(has_role("admin")) +): + """Crea un nuevo artículo.""" + import logging + logger = logging.getLogger(__name__) + logger.info(f"DEBUG: Creating new article: {article.title} by {current_user.get('preferred_username')}") + + # Fill last_editor with admin username + if current_user.get('preferred_username'): + article.last_editor = current_user.get('preferred_username') + + new_article = HelpCenterService.create(db, article) + logger.info(f"DEBUG: Article created successfully in DB. UUID: {new_article.uuid}") + + trigger_sync_or_broadcast(new_article.uuid) + + return new_article + +@router.patch("/articles/{article_uuid}/", response_model=HelpArticleInDB) +def update_article( + article_uuid: UUID, + article_data: HelpArticleUpdate, + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(has_role("admin")) +): + """Actualiza un artículo.""" + if current_user.get('preferred_username'): + article_data.last_editor = current_user.get('preferred_username') + + article = HelpCenterService.update(db, article_uuid, article_data) + if not article: + raise HTTPException(status_code=404, detail="Article not found") + + trigger_sync_or_broadcast(article.uuid) + + return article + +@router.delete("/articles/{article_uuid}/", status_code=status.HTTP_204_NO_CONTENT) +def delete_article( + article_uuid: UUID, + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(has_role("admin")) +): + """Elimina un artículo.""" + if not HelpCenterService.delete(db, article_uuid): + raise HTTPException(status_code=404, detail="Article not found") + + # Broadcast or Sync the deletion? + # Current sync logic relies on sending the *content*. Deletion sync is harder because the article is gone. + # For now, let's at least trigger the logic. + # WARNING: sync_single_article_task expects the article to exist to send it. + # If we deleted it locally, sync_single_article_task will fail or send nothing. + # We need a dedicated 'sync_deletion' task or similar. + # Since the user didn't explicitly ask for deletion sync, I will SKIP adding complex deletion sync + # logic right now to avoid breaking things, but I'll add the hook for completeness. + # Actually, better to NOT trigger sync on delete if we don't handle it, to avoid errors in logs. + + return None diff --git a/backend/api/v1/modules/core/help_center/schemas.py b/backend/api/v1/modules/core/help_center/schemas.py new file mode 100644 index 0000000..065e913 --- /dev/null +++ b/backend/api/v1/modules/core/help_center/schemas.py @@ -0,0 +1,74 @@ +from datetime import datetime +from typing import Optional +from uuid import UUID +from pydantic import BaseModel, Field + +class HelpArticleBase(BaseModel): + slug: str + title: str + content: str + last_editor: str + category: Optional[str] = "General" + order: Optional[int] = 0 + content_type: str = "article" + file_url: Optional[str] = None + file_size: Optional[int] = None + mime_type: Optional[str] = None + context_path: Optional[str] = None + tags: Optional[str] = None + +class HelpArticleCreate(HelpArticleBase): + pass + +class HelpArticleUpdate(BaseModel): + slug: Optional[str] = None + title: Optional[str] = None + content: Optional[str] = None + last_editor: Optional[str] = None + category: Optional[str] = None + order: Optional[int] = None + content_type: Optional[str] = None + file_url: Optional[str] = None + file_size: Optional[int] = None + mime_type: Optional[str] = None + context_path: Optional[str] = None + tags: Optional[str] = None + +class HelpArticleInDB(HelpArticleBase): + uuid: UUID + updated_at: datetime + + class Config: + from_attributes = True + +class HelpSyncRequest(BaseModel): + article_uuid: UUID + client_updated_at: datetime + client_content: str + client_title: str + client_slug: str + last_editor: str + client_category: Optional[str] = "General" + client_order: Optional[int] = 0 + client_content_type: str = "article" + client_file_url: Optional[str] = None + client_file_size: Optional[int] = None + client_mime_type: Optional[str] = None + client_context_path: Optional[str] = None + client_tags: Optional[str] = None + +class HelpSyncResponse(BaseModel): + status: str + server_updated_at: Optional[datetime] = None + server_content: Optional[str] = None + server_title: Optional[str] = None + server_slug: Optional[str] = None + server_category: Optional[str] = None + server_order: Optional[int] = None + server_content_type: Optional[str] = None + server_file_url: Optional[str] = None + server_file_size: Optional[int] = None + server_mime_type: Optional[str] = None + server_context_path: Optional[str] = None + server_tags: Optional[str] = None + message: str diff --git a/backend/api/v1/modules/core/help_center/services.py b/backend/api/v1/modules/core/help_center/services.py new file mode 100644 index 0000000..e31921b --- /dev/null +++ b/backend/api/v1/modules/core/help_center/services.py @@ -0,0 +1,257 @@ +import json +import re +from datetime import datetime, timezone +from typing import List, Optional +from uuid import UUID +from sqlalchemy.orm import Session +from .models import HelpArticle +from .schemas import HelpArticleCreate, HelpArticleUpdate, HelpSyncRequest, HelpSyncResponse + +class HelpCenterService: + @staticmethod + def _inject_metadata(article: HelpArticle) -> HelpArticle: + if not article or not article.content: + return article + + # Look for + match = re.search(r'', article.content, re.DOTALL) + if match: + try: + metadata = json.loads(match.group(1)) + article.content_type = metadata.get("content_type", "article") + article.file_url = metadata.get("file_url") + article.file_size = metadata.get("file_size") + article.mime_type = metadata.get("mime_type") + article.context_path = metadata.get("context_path") + article.tags = metadata.get("tags") + # Remove metadata from content for clean display if needed, + # but usually better to leave it and let parser handle it or hide it here. + # For now, we just set the attributes. + except Exception: + pass + else: + article.content_type = "article" + article.file_url = None + article.file_size = None + article.mime_type = None + article.context_path = None + article.tags = None + + return article + + @staticmethod + def _extract_metadata(content: str, data: dict) -> str: + # Remove existing metadata block if any + content = re.sub(r'\n\n', '', content, flags=re.DOTALL) + + metadata = { + "content_type": data.get("content_type", "article"), + "file_url": data.get("file_url"), + "file_size": data.get("file_size"), + "mime_type": data.get("mime_type"), + "context_path": data.get("context_path"), + "tags": data.get("tags") + } + + # Only append if there's something meaningful beyond "article" + if (metadata["content_type"] != "article" or + metadata["file_url"] or + metadata["context_path"] or + metadata["tags"]): + content += f"\n\n" + + return content + + @staticmethod + def get_all(db: Session) -> List[HelpArticle]: + articles = db.query(HelpArticle).all() + return [HelpCenterService._inject_metadata(a) for a in articles] + + @staticmethod + def get_by_uuid(db: Session, article_uuid: UUID) -> Optional[HelpArticle]: + article = db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid).first() + return HelpCenterService._inject_metadata(article) + + @staticmethod + def get_by_slug(db: Session, slug: str) -> Optional[HelpArticle]: + article = db.query(HelpArticle).filter(HelpArticle.slug == slug).first() + return HelpCenterService._inject_metadata(article) + + @staticmethod + def get_modifications(db: Session, since: datetime) -> List[HelpArticle]: + # Ensure timezone awareness + if since.tzinfo is None: + since = since.replace(tzinfo=timezone.utc) + articles = db.query(HelpArticle).filter(HelpArticle.updated_at > since).all() + return [HelpCenterService._inject_metadata(a) for a in articles] + + @staticmethod + def create(db: Session, article: HelpArticleCreate) -> HelpArticle: + data = article.model_dump() + # Move metadata into content + data["content"] = HelpCenterService._extract_metadata(data["content"], data) + # Remove virtual fields from data to avoid SQLAlchemy errors + virtual_fields = ["content_type", "file_url", "file_size", "mime_type", "context_path", "tags"] + for f in virtual_fields: + if f in data: + del data[f] + + db_article = HelpArticle(**data) + db.add(db_article) + db.commit() + db.refresh(db_article) + return HelpCenterService._inject_metadata(db_article) + + @staticmethod + def update(db: Session, article_uuid: UUID, article_data: HelpArticleUpdate) -> Optional[HelpArticle]: + db_article = db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid).first() + if not db_article: + return None + + # Inject metadata to existing article to get current virtual fields + db_article = HelpCenterService._inject_metadata(db_article) + + update_data = article_data.model_dump(exclude_unset=True) + + # Handle metadata update + if "content" in update_data or any(f in update_data for f in ["content_type", "file_url", "file_size", "mime_type", "context_path", "tags"]): + # Merge existing metadata with new updates + current_meta = { + "content_type": getattr(db_article, "content_type", "article"), + "file_url": getattr(db_article, "file_url", None), + "file_size": getattr(db_article, "file_size", None), + "mime_type": getattr(db_article, "mime_type", None), + "context_path": getattr(db_article, "context_path", None), + "tags": getattr(db_article, "tags", None) + } + # Update with new data if present + for f in ["content_type", "file_url", "file_size", "mime_type", "context_path", "tags"]: + if f in update_data: + current_meta[f] = update_data[f] + + # Use current content or new content + content = update_data.get("content", db_article.content) + update_data["content"] = HelpCenterService._extract_metadata(content, current_meta) + + # Remove virtual fields from data + virtual_fields = ["content_type", "file_url", "file_size", "mime_type", "context_path", "tags"] + for f in virtual_fields: + if f in update_data: + del update_data[f] + + for key, value in update_data.items(): + setattr(db_article, key, value) + + db.commit() + db.refresh(db_article) + return HelpCenterService._inject_metadata(db_article) + + @staticmethod + def delete(db: Session, article_uuid: UUID) -> bool: + db_article = db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid).first() + if not db_article: + return False + db.delete(db_article) + db.commit() + return True + + @staticmethod + def sync_article(db: Session, sync_data: HelpSyncRequest) -> HelpSyncResponse: + """ + Lógica de sincronización "Smart Sync" (Last Write Wins). + """ + db_article = db.query(HelpArticle).filter(HelpArticle.uuid == sync_data.article_uuid).first() + + client_updated_at = sync_data.client_updated_at + if client_updated_at.tzinfo is None: + client_updated_at = client_updated_at.replace(tzinfo=timezone.utc) + + if not db_article: + # Caso A: Artículo nuevo desde el cliente + # Store metadata in content + client_meta = { + "content_type": sync_data.client_content_type, + "file_url": sync_data.client_file_url, + "file_size": sync_data.client_file_size, + "mime_type": sync_data.client_mime_type, + "context_path": sync_data.client_context_path, + "tags": sync_data.client_tags + } + content_with_meta = HelpCenterService._extract_metadata(sync_data.client_content, client_meta) + + new_article = HelpArticle( + uuid=sync_data.article_uuid, + slug=sync_data.client_slug, + title=sync_data.client_title, + content=content_with_meta, + updated_at=client_updated_at, + last_editor=sync_data.last_editor, + category=sync_data.client_category, + order=sync_data.client_order + ) + db.add(new_article) + db.commit() + + # Download assets if needed (Images in content and main file) + from .utils import download_file_from_hub, sync_assets_from_content + if sync_data.client_file_url: + download_file_from_hub(sync_data.client_file_url) + sync_assets_from_content(sync_data.client_content) + + return HelpSyncResponse(status="OK", message="Article created on server.") + + server_updated_at = db_article.updated_at + if server_updated_at.tzinfo is None: + server_updated_at = server_updated_at.replace(tzinfo=timezone.utc) + + # Caso A: Cliente es más nuevo + if client_updated_at > server_updated_at: + client_meta = { + "content_type": sync_data.client_content_type, + "file_url": sync_data.client_file_url, + "file_size": sync_data.client_file_size, + "mime_type": sync_data.client_mime_type, + "context_path": sync_data.client_context_path, + "tags": sync_data.client_tags + } + db_article.content = HelpCenterService._extract_metadata(sync_data.client_content, client_meta) + db_article.title = sync_data.client_title + db_article.slug = sync_data.client_slug + db_article.updated_at = client_updated_at + db_article.last_editor = sync_data.last_editor + db_article.category = sync_data.client_category + db_article.order = sync_data.client_order + db.commit() + + # Download assets if needed (Images in content) + from .utils import download_file_from_hub, sync_assets_from_content + if sync_data.client_file_url: + download_file_from_hub(sync_data.client_file_url) + sync_assets_from_content(sync_data.client_content) + + return HelpSyncResponse(status="OK", message="Server updated with client data.") + + # Caso B: Servidor es más nuevo + elif server_updated_at > client_updated_at: + # Inject metadata for response + db_article = HelpCenterService._inject_metadata(db_article) + return HelpSyncResponse( + status="UPDATE_REQUIRED", + server_updated_at=server_updated_at, + server_content=db_article.content, + server_title=db_article.title, + server_slug=db_article.slug, + server_category=db_article.category, + server_order=db_article.order, + server_content_type=getattr(db_article, "content_type", "article"), + server_file_url=getattr(db_article, "file_url", None), + server_file_size=getattr(db_article, "file_size", None), + server_mime_type=getattr(db_article, "mime_type", None), + server_context_path=getattr(db_article, "context_path", None), + server_tags=getattr(db_article, "tags", None), + message="Client is outdated. Update required." + ) + + # Caso C: Iguales + else: + return HelpSyncResponse(status="OK", message="Already in sync.") diff --git a/backend/api/v1/modules/core/help_center/tasks.py b/backend/api/v1/modules/core/help_center/tasks.py new file mode 100644 index 0000000..e9c2c6a --- /dev/null +++ b/backend/api/v1/modules/core/help_center/tasks.py @@ -0,0 +1,269 @@ +import logging +import httpx +from uuid import UUID +from celery import shared_task +from datetime import datetime, timezone +from core.database import CoreSessionLocal +from core.config import settings +from .models import HelpArticle +from .schemas import HelpSyncRequest, HelpSyncResponse + +logger = logging.getLogger(__name__) + +@shared_task(name="sync_all_articles_task") +def sync_all_articles_task(): + """ + Tarea periódica que recorre todos los artículos locales y los sincroniza con el Central. + Solo se ejecuta si hay un CENTRAL_SERVER_URL configurado (Rol: Cliente/Spoke). + """ + if not settings.CENTRAL_SERVER_URL: + logger.info("Skipping sync: No CENTRAL_SERVER_URL configured (Hub mode).") + return + db = CoreSessionLocal() + try: + articles = db.query(HelpArticle).all() + for article in articles: + sync_single_article(article.uuid) + except Exception as e: + logger.error(f"Error in sync_all_articles_task: {e}") + finally: + db.close() + +@shared_task(name="sync_single_article_task") +def sync_single_article_task(article_uuid_str: str): + """ + Sincroniza un único artículo inmediatamente después de una edición local. + """ + sync_single_article(article_uuid_str) + + +@shared_task(name="broadcast_help_update") +def broadcast_help_update(article_uuid_str: str): + """ + Difunde una actualización de artículo a todos los spokes configurados. + """ + if not settings.SPOKE_URLS: + logger.info("No SPOKE_URLS configured. Skipping broadcast.") + return + + spokes = [s.strip() for s in settings.SPOKE_URLS.split(",") if s.strip()] + headers = {"X-Sync-Token": settings.SYNC_SECRET_TOKEN} + + db = CoreSessionLocal() + try: + article = db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid_str).first() + if not article: + logger.error(f"Article {article_uuid_str} not found for broadcast.") + return + + sync_payload = HelpSyncRequest( + article_uuid=article.uuid, + client_updated_at=article.updated_at, + client_content=article.content, + client_title=article.title, + client_slug=article.slug, + last_editor=article.last_editor, + client_category=article.category, + client_order=article.order + ).model_dump(mode='json') + + with httpx.Client() as client: + for spoke_url in spokes: + # Loop Prevention: Skip if the spoke is the origin + try: + logger.info(f"Broadcasting update to {spoke_url}") + response = client.post( + spoke_url, + json=sync_payload, + headers=headers, + timeout=5.0 + ) + if response.status_code != 200: + logger.warning(f"Broadcast to {spoke_url} failed: {response.status_code}") + except Exception as e: + logger.error(f"Error broadcasting to {spoke_url}: {e}") + + except Exception as e: + logger.error(f"Broadcast error: {e}") + finally: + db.close() + + +def sync_single_article(article_uuid): + """ + Lógica compartida para sincronizar un artículo con el servidor central. + """ + logger.info(f"DEBUG: Syncing article {article_uuid}. CENTRAL_SERVER_URL='{settings.CENTRAL_SERVER_URL}' (Type: {type(settings.CENTRAL_SERVER_URL)})") + + if not settings.CENTRAL_SERVER_URL or settings.CENTRAL_SERVER_URL == '""': + # Enhanced check to catch literal empty quotes if they slip through + return + + db = CoreSessionLocal() + try: + article = db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid).first() + if not article: + return + + sync_data = HelpSyncRequest( + article_uuid=article.uuid, + client_updated_at=article.updated_at, + client_content=article.content, + client_title=article.title, + client_slug=article.slug, + last_editor=article.last_editor, + client_category=article.category, + client_order=article.order + ) + + headers = {"X-Sync-Token": settings.SYNC_SECRET_TOKEN} + + with httpx.Client() as client: + response = client.post( + settings.CENTRAL_SERVER_URL, + json=sync_data.model_dump(mode='json'), + headers=headers, + timeout=10.0 + ) + + if response.status_code == 200: + result = HelpSyncResponse(**response.json()) + if result.status == "UPDATE_REQUIRED": + # El servidor tiene una versión más nueva, actualizamos localmente + article.content = result.server_content + article.title = result.server_title + article.slug = result.server_slug + article.updated_at = result.server_updated_at + db.commit() + logger.info(f"Article {article.uuid} updated from server.") + + # Download assets if needed + from .utils import download_file_from_hub, sync_assets_from_content + if result.server_file_url: + download_file_from_hub(result.server_file_url) + sync_assets_from_content(result.server_content) + else: + logger.info(f"Article {article.uuid} sync OK: {result.message}") + else: + logger.error(f"Sync failed for article {article.uuid}: {response.status_code} - {response.text}") + + except Exception as e: + logger.error(f"Error syncing article {article.uuid}: {e}") + finally: + db.close() + +from sqlalchemy import func + +@shared_task(name="sync_from_hub_task") +def sync_from_hub_task(): + """ + Tarea de POLLING que el Cliente ejecuta periódicamente. + Consulta al Hub (CENTRAL_SERVER_URL) por artículos modificados desde + la última actualización local. + """ + if not settings.CENTRAL_SERVER_URL: + return + + db = CoreSessionLocal() + try: + # 1. Obtener la fecha de la última actualización local + last_local_update = db.query(func.max(HelpArticle.updated_at)).scalar() + if not last_local_update: + # Si no hay datos, traer todo desde el principio de los tiempos + last_local_update = datetime(2000, 1, 1, tzinfo=timezone.utc) + + # Asegurar timezone awareness + if last_local_update.tzinfo is None: + last_local_update = last_local_update.replace(tzinfo=timezone.utc) + + logger.info(f"Polling Hub for updates since {last_local_update}") + + # 2. Consultar al Hub + headers = {"X-Sync-Token": settings.SYNC_SECRET_TOKEN} + # CENTRAL_SERVER_URL es ".../help-center/sync/" + # Queremos ".../help-center/modifications/" + hub_url = settings.CENTRAL_SERVER_URL.replace("/sync/", "/modifications/") + + with httpx.Client() as client: + response = client.get( + hub_url, + params={"since": last_local_update.isoformat()}, + headers=headers, + timeout=10.0 + ) + + if response.status_code == 200: + articles_data = response.json() + if not articles_data: + logger.info("No updates found.") + return + + logger.info(f"Found {len(articles_data)} updates from Hub. Applying...") + + # 3. Aplicar actualizaciones + for art_data in articles_data: + try: + # Logic similar to sync_article but simpler (Force Update from Hub) + # We assume Hub is Truth in this Polling flow + + # Try to find by UUID + local_article = db.query(HelpArticle).filter(HelpArticle.uuid == art_data['uuid']).first() + + # Fallback: find by Slug if UUID doesn't match + if not local_article: + local_article = db.query(HelpArticle).filter(HelpArticle.slug == art_data['slug']).first() + + server_updated_at = datetime.fromisoformat(art_data['updated_at']) + if server_updated_at.tzinfo is None: + server_updated_at = server_updated_at.replace(tzinfo=timezone.utc) + + if not local_article: + new_article = HelpArticle( + uuid=art_data['uuid'], + slug=art_data['slug'], + title=art_data['title'], + content=art_data['content'], + updated_at=server_updated_at, + last_editor=art_data['last_editor'], + category=art_data.get('category', "General"), + order=art_data.get('order', 0) + ) + db.add(new_article) + logger.info(f"Created new article: {art_data['slug']}") + else: + # Update existing article + # If UUID changed in Hub but slug is the same, we update UUID too + local_article.uuid = art_data['uuid'] + local_article.slug = art_data['slug'] + local_article.title = art_data['title'] + local_article.content = art_data['content'] + local_article.updated_at = server_updated_at + local_article.last_editor = art_data['last_editor'] + local_article.category = art_data.get('category', "General") + local_article.order = art_data.get('order', 0) + logger.info(f"Updated article: {art_data['slug']}") + + db.commit() # Commit each article to avoid bulk failure + except Exception as e: + db.rollback() + logger.error(f"Error syncing article {art_data.get('slug', 'unknown')}: {e}") + + + # Download assets after bulk update (Polling) + from .utils import download_file_from_hub, sync_assets_from_content + for art_data in articles_data: + # art_data contains the virtual fields because it was dumped via HelpArticleInDB + if "file_url" in art_data and art_data['file_url']: + download_file_from_hub(art_data['file_url']) + + sync_assets_from_content(art_data.get('content', '')) + + logger.info("Polling sync completed successfully.") + + else: + logger.error(f"Polling failed: {response.status_code} - {response.text}") + + except Exception as e: + logger.error(f"Error in sync_from_hub_task: {e}") + finally: + db.close() diff --git a/backend/api/v1/modules/core/help_center/utils.py b/backend/api/v1/modules/core/help_center/utils.py new file mode 100644 index 0000000..8d9b785 --- /dev/null +++ b/backend/api/v1/modules/core/help_center/utils.py @@ -0,0 +1,111 @@ +import logging +import mimetypes +import os +import re +from pathlib import Path +from typing import Optional + +import httpx + +from core.config import settings +from core.s3_keys import SYSTEM_HELP_PREFIX +from core.storage_s3 import object_exists, put_object_bytes + +logger = logging.getLogger(__name__) + + +def _asset_url_to_s3_key(asset_url: str) -> Optional[str]: + """Deriva la clave S3 bajo system/help/ a partir de una URL de artículo.""" + if "/help-center/files/" in asset_url: + rel = asset_url.split("/help-center/files/", 1)[1].lstrip("/") + if ".." in rel: + return None + return f"{SYSTEM_HELP_PREFIX}{rel}" + u = asset_url.replace("/api/uploads/", "uploads/") + if u.startswith("/"): + u = u[1:] + if u.startswith("uploads/help/"): + return f"{SYSTEM_HELP_PREFIX}{u[len('uploads/help/') :]}" + return None + + +def download_file_from_hub(relative_path: str) -> bool: + """ + Descarga un asset del Hub y lo guarda en MinIO (system/help/...) o en disco si no hay almacenamiento S3 activo. + relative_path: URL parcial, p. ej. '/api/uploads/help/x.png' o '/api/v1/core/help-center/files/pdfs/x.pdf' + """ + if not settings.CENTRAL_SERVER_URL or settings.CENTRAL_SERVER_URL == '""': + return False + + key = _asset_url_to_s3_key(relative_path) + if not key: + logger.warning("download_file_from_hub: could not map URL to S3 key: %s", relative_path) + return False + + if settings.use_s3_object_storage and object_exists(key): + logger.info("S3 object %s already exists, skipping download.", key) + return True + + base_url = settings.CENTRAL_SERVER_URL.split("/v1/")[0] + if "/help-center/files/" in relative_path: + rel = relative_path.split("/help-center/files/", 1)[1].lstrip("/") + hub_file_url = f"{base_url.rstrip('/')}/api/v1/core/help-center/files/{rel}" + else: + clean_path = relative_path.replace("/api/uploads/", "uploads/") + if clean_path.startswith("/"): + clean_path = clean_path[1:] + hub_file_url = f"{base_url.rstrip('/')}/{clean_path}" + + logger.info("Downloading asset from Hub: %s", hub_file_url) + + try: + with httpx.Client() as client: + response = client.get(hub_file_url, timeout=30.0) + if response.status_code != 200: + logger.warning( + "Failed to download %s: Status %s URL: %s", + relative_path, + response.status_code, + hub_file_url, + ) + return False + body = response.content + except Exception as e: + logger.error("Error downloading %s: %s", relative_path, str(e)) + return False + + if settings.use_s3_object_storage: + rel = key[len(SYSTEM_HELP_PREFIX) :] + ct = mimetypes.guess_type(rel)[0] or "application/octet-stream" + try: + put_object_bytes(key, body, content_type=ct) + logger.info("Stored hub asset in S3: %s", key) + return True + except Exception as e: + logger.error("S3 put failed for %s: %s", key, e) + return False + + rel = key[len(SYSTEM_HELP_PREFIX) :] + local_path = Path("uploads/help") / rel + local_path.parent.mkdir(parents=True, exist_ok=True) + local_path.write_bytes(body) + logger.info("Stored hub asset locally: %s", local_path) + return True + + +def sync_assets_from_content(content: str): + """Parsea markdown y descarga imágenes referenciadas (rutas legacy y nuevas).""" + if not content: + return + + patterns = [ + r'!\[.*?\]\((/api/uploads/.*?)\)', + r'!\[.*?\]\((/api/v1/core/help-center/files/.*?)\)', + ] + seen = set() + for pattern in patterns: + for asset_url in re.findall(pattern, content): + if asset_url in seen: + continue + seen.add(asset_url) + download_file_from_hub(asset_url) diff --git a/backend/api/v1/modules/core/invite_codes/__init__.py b/backend/api/v1/modules/core/invite_codes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/v1/modules/core/invite_codes/dto.py b/backend/api/v1/modules/core/invite_codes/dto.py new file mode 100644 index 0000000..379ccbe --- /dev/null +++ b/backend/api/v1/modules/core/invite_codes/dto.py @@ -0,0 +1,51 @@ +"""DTOs para el módulo de códigos de invitación.""" + +from datetime import datetime +from typing import List, Optional + +from pydantic import BaseModel, Field + + +class CreateInviteCodeDTO(BaseModel): + company_id: Optional[int] = Field( + None, description="Empresa destino (None = cualquier empresa del tenant)" + ) + role: str = Field("user", description="Rol asignado al canjear el código") + max_uses: Optional[int] = Field(None, description="Usos máximos (None = ilimitado)") + expires_at: Optional[datetime] = Field(None, description="Expiración (None = sin expiración)") + + +class InviteCodeResponseDTO(BaseModel): + id: int + code: str + tenant_slug: str + company_id: Optional[int] + role: str + max_uses: Optional[int] + uses_count: int + expires_at: Optional[datetime] + is_active: bool + created_by: str + created_at: datetime + + class Config: + from_attributes = True + + +class ValidateInviteCodeResponseDTO(BaseModel): + code: str + tenant_slug: str + company_id: Optional[int] + role: str + remaining_uses: Optional[int] = Field( + None, description="Usos restantes; None = ilimitado" + ) + expires_at: Optional[datetime] + + +class ConsumeInviteCodeResponseDTO(BaseModel): + success: bool + message: str + tenant_slug: str + company_id: Optional[int] + role: str diff --git a/backend/api/v1/modules/core/invite_codes/models.py b/backend/api/v1/modules/core/invite_codes/models.py new file mode 100644 index 0000000..c7aebe0 --- /dev/null +++ b/backend/api/v1/modules/core/invite_codes/models.py @@ -0,0 +1,49 @@ +"""Modelo de código de invitación reutilizable para registro.""" + +from datetime import datetime +from typing import Optional + +from api.v1.common.base_models import BaseTimestampMixin +from core.database import Base +from sqlalchemy import Boolean, DateTime, Integer, String +from sqlalchemy.orm import Mapped, mapped_column + + +class InviteCode(Base, BaseTimestampMixin): + """ + Código corto multiuso para invitar usuarios a un tenant/empresa. + A diferencia de InviteToken (único por email), un InviteCode es + compartible: se distribuye como cadena de 8 chars y puede + ser canjeado por múltiples usuarios hasta agotar max_uses. + """ + + __tablename__ = "invite_codes" + __table_args__ = {"schema": "core", "extend_existing": True} + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + + # Código legible generado automáticamente (8 chars, sin ambigüedad 0/O/I/l) + code: Mapped[str] = mapped_column(String(16), unique=True, nullable=False, index=True) + + # Tenant destino — el usuario debe unirse a este workspace en el Hub + tenant_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + + # Empresa destino específica (None = cualquier empresa del tenant) + company_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True) + + # Rol con el que se provisiona el usuario al canjear + role: Mapped[str] = mapped_column(String(50), nullable=False, server_default="user") + + # Control de uso + max_uses: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + uses_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0", default=0) + + # Expiración (None = sin expiración) + expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + + # keycloak_user_id del admin que generó el código + created_by: Mapped[str] = mapped_column(String(255), nullable=False) + + is_active: Mapped[bool] = mapped_column( + Boolean, nullable=False, server_default="true", default=True + ) diff --git a/backend/api/v1/modules/core/invite_codes/routes.py b/backend/api/v1/modules/core/invite_codes/routes.py new file mode 100644 index 0000000..674ef3c --- /dev/null +++ b/backend/api/v1/modules/core/invite_codes/routes.py @@ -0,0 +1,149 @@ +"""Rutas para gestión de códigos de invitación.""" + +import logging +from typing import List, Optional + +from core.database import get_core_db +from core.security import get_current_user, validate_access_to_resource +from fastapi import APIRouter, Depends, Query +from fastapi.security import HTTPBearer +from sqlalchemy.orm import Session + +from .dto import ( + ConsumeInviteCodeResponseDTO, + CreateInviteCodeDTO, + InviteCodeResponseDTO, + ValidateInviteCodeResponseDTO, +) +from .service import InviteCodeService + +router = APIRouter(prefix="/invite-codes", tags=["Invite Codes"]) +_bearer = HTTPBearer() + +logger = logging.getLogger(__name__) + + +@router.post("", response_model=InviteCodeResponseDTO, status_code=201) +async def create_invite_code( + data: CreateInviteCodeDTO, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), + credentials=Depends(_bearer), +): + """ + Genera un código de invitación reutilizable. + Requiere permiso user.create sobre la empresa (o ser admin del tenant). + """ + company_id = data.company_id + if company_id is not None: + validate_access_to_resource( + db, + company_id, + current_user, + required_permissions=["user.create"], + ) + else: + # Invitación a nivel tenant: solo roles admin del tenant + roles = set(current_user.get("roles") or []) + if "admin" not in roles and "hub_admin" not in roles: + from fastapi import HTTPException + raise HTTPException( + status_code=403, + detail="Se requiere rol admin para crear invitaciones de nivel tenant", + ) + + tenant_slug: str = current_user.get("tenant_slug") or "" + created_by: str = current_user.get("sub") or "" + + service = InviteCodeService(db) + return await service.create_code( + data=data, + created_by=created_by, + tenant_slug=tenant_slug, + user_access_token=credentials.credentials, + ) + + +@router.get("", response_model=List[InviteCodeResponseDTO]) +def list_invite_codes( + company_id: Optional[int] = Query(None, description="Filtrar por empresa"), + include_inactive: bool = Query(False, description="Incluir códigos inactivos/agotados"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Lista los códigos de invitación del tenant. + Filtra opcionalmente por empresa. + """ + if company_id is not None: + validate_access_to_resource( + db, + company_id, + current_user, + required_permissions=["user.create"], + ) + else: + roles = set(current_user.get("roles") or []) + if "admin" not in roles and "hub_admin" not in roles: + from fastapi import HTTPException + raise HTTPException(status_code=403, detail="Se requiere rol admin") + + tenant_slug: str = current_user.get("tenant_slug") or "" + service = InviteCodeService(db) + return service.list_codes( + tenant_slug=tenant_slug, + company_id=company_id, + include_inactive=include_inactive, + ) + + +@router.delete("/{code}", status_code=204) +def revoke_invite_code( + code: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """Revoca (desactiva) un código de invitación.""" + roles = set(current_user.get("roles") or []) + if "admin" not in roles and "hub_admin" not in roles: + from fastapi import HTTPException + raise HTTPException(status_code=403, detail="Se requiere rol admin") + + tenant_slug: str = current_user.get("tenant_slug") or "" + service = InviteCodeService(db) + service.revoke_code(code=code, tenant_slug=tenant_slug) + + +@router.get("/validate/{code}", response_model=ValidateInviteCodeResponseDTO) +def validate_invite_code( + code: str, + db: Session = Depends(get_core_db), +): + """ + Valida un código de invitación sin consumirlo. + Endpoint público — no requiere autenticación. + """ + service = InviteCodeService(db) + return service.validate(code=code) + + +@router.post("/consume/{code}", response_model=ConsumeInviteCodeResponseDTO) +def consume_invite_code( + code: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Canjea el código: incrementa el contador de usos y crea la relación + UserTenant (usuario ↔ empresa) si el código tiene company_id definido. + Requiere autenticación. + """ + keycloak_user_id: str = current_user.get("sub") or "" + tenant_id: int = current_user.get("tenant_id") or 0 + + service = InviteCodeService(db) + return service.consume( + code=code, + keycloak_user_id=keycloak_user_id, + tenant_id=tenant_id, + ) diff --git a/backend/api/v1/modules/core/invite_codes/service.py b/backend/api/v1/modules/core/invite_codes/service.py new file mode 100644 index 0000000..7f5f9c4 --- /dev/null +++ b/backend/api/v1/modules/core/invite_codes/service.py @@ -0,0 +1,313 @@ +"""Servicio de códigos de invitación reutilizables.""" + +import logging +import random +import string +from datetime import datetime, timezone +from typing import List, Optional + +import httpx +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from core.config import settings +from .dto import ( + ConsumeInviteCodeResponseDTO, + CreateInviteCodeDTO, + InviteCodeResponseDTO, + ValidateInviteCodeResponseDTO, +) +from .models import InviteCode + +logger = logging.getLogger(__name__) + +# Charset sin caracteres ambiguos (0/O/I/l/1) +_CODE_CHARSET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" +_CODE_LENGTH = 8 + + +def _generate_code() -> str: + return "".join(random.choices(_CODE_CHARSET, k=_CODE_LENGTH)) + + +def _is_valid(invite: InviteCode) -> bool: + """True si el código es canjeable en este momento.""" + if not invite.is_active: + return False + if invite.max_uses is not None and invite.uses_count >= invite.max_uses: + return False + if invite.expires_at and invite.expires_at < datetime.now(timezone.utc): + return False + return True + + +class InviteCodeService: + def __init__(self, db: Session): + self.db = db + + async def create_code( + self, + data: CreateInviteCodeDTO, + created_by: str, + tenant_slug: str, + user_access_token: str = "", + ) -> InviteCodeResponseDTO: + from api.v1.modules.core.tenants.models import Tenant + + tenant = ( + self.db.query(Tenant) + .filter(Tenant.slug == tenant_slug, Tenant.is_active == True) + .first() + ) + if not tenant: + raise HTTPException(status_code=404, detail="Tenant no encontrado") + + if data.company_id is not None: + # Implementa la validación de company con tu modelo de compañía. + company = None + if not company: + raise HTTPException( + status_code=404, + detail="Empresa no encontrada o no pertenece al tenant", + ) + + # Genera código único; reintenta si hay colisión (improbable) + for _ in range(5): + code = _generate_code() + if not self.db.query(InviteCode).filter(InviteCode.code == code).first(): + break + else: + raise HTTPException( + status_code=500, + detail="No se pudo generar un código único, intenta de nuevo", + ) + + invite = InviteCode( + code=code, + tenant_slug=tenant_slug, + company_id=data.company_id, + role=data.role, + max_uses=data.max_uses, + uses_count=0, + expires_at=data.expires_at, + created_by=created_by, + is_active=True, + ) + self.db.add(invite) + self.db.commit() + self.db.refresh(invite) + + # Registrar el mismo código en el Hub para que funcione en workspace /join + await self._sync_code_to_hub( + code=code, + tenant_slug=tenant_slug, + data=data, + user_access_token=user_access_token, + ) + + return InviteCodeResponseDTO.model_validate(invite) + + async def _sync_code_to_hub( + self, + code: str, + tenant_slug: str, + data: CreateInviteCodeDTO, + user_access_token: str, + ) -> None: + """ + Crea el mismo código en Hub's workspace_invite_codes con allowed_systems=['a76']. + Best-effort: si falla, el código sigue válido en A76 pero no en workspace. + """ + if not user_access_token: + logger.warning( + "[invite_code] Sin token para sincronizar '%s' al Hub — " + "el código NO funcionará en workspace /join", + code, + ) + return + + hub_url = getattr(settings, "HUB_URL", "").rstrip("/") + if not hub_url: + logger.warning("[invite_code] HUB_URL no configurado — código '%s' no sincronizado", code) + return + + payload: dict = { + "code": code, + "allowed_systems": [], + "role": data.role, + } + if data.max_uses is not None: + payload["max_uses"] = data.max_uses + if data.expires_at is not None: + payload["expires_at"] = data.expires_at.isoformat() + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.post( + f"{hub_url}/api/v1/hub/invite-codes/{tenant_slug}", + json=payload, + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + if resp.status_code in (200, 201): + logger.info( + "[invite_code] Código '%s' sincronizado al Hub (tenant=%s)", code, tenant_slug + ) + elif resp.status_code == 409: + logger.info( + "[invite_code] Código '%s' ya existe en Hub (tenant=%s) — OK", code, tenant_slug + ) + else: + logger.error( + "[invite_code] Hub sync falló code='%s' status=%s body=%s", + code, resp.status_code, resp.text[:300], + ) + except Exception as exc: + logger.error("[invite_code] Hub sync excepción code='%s': %s", code, exc) + + def list_codes( + self, + tenant_slug: str, + company_id: Optional[int] = None, + include_inactive: bool = False, + ) -> List[InviteCodeResponseDTO]: + q = self.db.query(InviteCode).filter(InviteCode.tenant_slug == tenant_slug) + + if company_id is not None: + q = q.filter(InviteCode.company_id == company_id) + + if not include_inactive: + q = q.filter(InviteCode.is_active == True) + + invites = q.order_by(InviteCode.created_at.desc()).all() + return [InviteCodeResponseDTO.model_validate(i) for i in invites] + + def revoke_code(self, code: str, tenant_slug: str) -> None: + invite = ( + self.db.query(InviteCode) + .filter(InviteCode.code == code, InviteCode.tenant_slug == tenant_slug) + .first() + ) + if not invite: + raise HTTPException(status_code=404, detail="Código de invitación no encontrado") + + invite.is_active = False + self.db.commit() + + def validate(self, code: str) -> ValidateInviteCodeResponseDTO: + """Valida el código sin consumirlo. Devuelve 403 genérico si no es válido.""" + invite = self.db.query(InviteCode).filter(InviteCode.code == code).first() + + if not invite or not _is_valid(invite): + raise HTTPException( + status_code=403, + detail="Código de invitación inválido o expirado", + ) + + remaining: Optional[int] = None + if invite.max_uses is not None: + remaining = invite.max_uses - invite.uses_count + + return ValidateInviteCodeResponseDTO( + code=invite.code, + tenant_slug=invite.tenant_slug, + company_id=invite.company_id, + role=invite.role, + remaining_uses=remaining, + expires_at=invite.expires_at, + ) + + def consume( + self, + code: str, + keycloak_user_id: str, + tenant_id: int, + ) -> ConsumeInviteCodeResponseDTO: + """ + Canjea el código: + - Incrementa uses_count. + - Si company_id está definido, crea UserTenant (usuario ↔ empresa). + - Desactiva el código si se agotaron los usos. + """ + invite = self.db.query(InviteCode).filter(InviteCode.code == code).first() + + if not invite or not _is_valid(invite): + raise HTTPException( + status_code=403, + detail="Código de invitación inválido o expirado", + ) + + if invite.company_id is not None: + self._ensure_user_tenant( + keycloak_user_id=keycloak_user_id, + tenant_id=tenant_id, + company_id=invite.company_id, + role=invite.role, + ) + + invite.uses_count += 1 + if invite.max_uses is not None and invite.uses_count >= invite.max_uses: + invite.is_active = False + + self.db.commit() + + logger.info( + "[invite_code] canjeado code=%s user=%s company_id=%s uses=%d/%s", + invite.code, + keycloak_user_id, + invite.company_id, + invite.uses_count, + invite.max_uses or "∞", + ) + + return ConsumeInviteCodeResponseDTO( + success=True, + message="Código canjeado correctamente", + tenant_slug=invite.tenant_slug, + company_id=invite.company_id, + role=invite.role, + ) + + def _ensure_user_tenant( + self, + keycloak_user_id: str, + tenant_id: int, + company_id: int, + role: str, + ) -> None: + """Crea la fila UserTenant si el usuario aún no tiene acceso a la empresa.""" + from sqlalchemy.exc import IntegrityError + from api.v1.modules.core.user_tenant.models import UserTenant + + existing = ( + self.db.query(UserTenant) + .filter( + UserTenant.keycloak_user_id == keycloak_user_id, + UserTenant.tenant_id == tenant_id, + UserTenant.company_id == company_id, + ) + .first() + ) + if existing: + if not existing.is_active: + existing.is_active = True + existing.role = role + self.db.commit() + return + + user_tenant = UserTenant( + keycloak_user_id=keycloak_user_id, + tenant_id=tenant_id, + company_id=company_id, + role=role, + is_active=True, + ) + self.db.add(user_tenant) + try: + self.db.commit() + except IntegrityError: + self.db.rollback() + logger.warning( + "[invite_code] race: UserTenant ya existe user=%s company=%d", + keycloak_user_id, + company_id, + ) diff --git a/backend/api/v1/modules/core/invites/__init__.py b/backend/api/v1/modules/core/invites/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/v1/modules/core/invites/dto.py b/backend/api/v1/modules/core/invites/dto.py new file mode 100644 index 0000000..0a9a8f5 --- /dev/null +++ b/backend/api/v1/modules/core/invites/dto.py @@ -0,0 +1,32 @@ +"""DTOs para el módulo de invitaciones.""" + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, EmailStr + + +class CreateInviteDTO(BaseModel): + email: EmailStr + company_id: int + role_id: int + + +class InviteResponseDTO(BaseModel): + id: int + email: str + role: str + expires_at: datetime + invite_url: str + created_at: datetime + + class Config: + from_attributes = True + + +class InviteValidationResult(BaseModel): + email: str + role: str + invite_id: int + tenant_slug: str + company_id: Optional[int] = None diff --git a/backend/api/v1/modules/core/invites/models.py b/backend/api/v1/modules/core/invites/models.py new file mode 100644 index 0000000..b4f1d06 --- /dev/null +++ b/backend/api/v1/modules/core/invites/models.py @@ -0,0 +1,42 @@ +"""Modelo de token de invitación local para registro de usuarios.""" + +from datetime import datetime +from typing import Optional + +from api.v1.common.base_models import BaseTimestampMixin +from core.database import Base +from sqlalchemy import DateTime, Integer, JSON, String +from sqlalchemy.orm import Mapped, mapped_column + + +class InviteToken(Base, BaseTimestampMixin): + """ + Token de invitación de un solo uso para registro de usuarios. + El token en claro NUNCA se almacena; solo su hash SHA-256. + """ + + __tablename__ = "invite_tokens" + __table_args__ = {"schema": "core", "extend_existing": True} + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + + # sha256(token_plain) — índice único + token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True) + + tenant_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + email: Mapped[str] = mapped_column(String(255), nullable=False) + role: Mapped[str] = mapped_column(String(50), nullable=False, server_default="user") + + # keycloak_user_id del admin que generó la invitación + created_by: Mapped[str] = mapped_column(String(255), nullable=False) + + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + + product_ids: Mapped[Optional[list]] = mapped_column(JSON, nullable=True) + + # Específico de Anexo76: empresa destino para crear UserTenant + company_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + + # Token generado en el Hub (para la URL de registro del workspace) + hub_invite_token: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) diff --git a/backend/api/v1/modules/core/invites/routes.py b/backend/api/v1/modules/core/invites/routes.py new file mode 100644 index 0000000..a134440 --- /dev/null +++ b/backend/api/v1/modules/core/invites/routes.py @@ -0,0 +1,49 @@ +"""Rutas para gestión de invitaciones de usuarios.""" + +import logging + +from core.database import get_core_db +from core.security import get_current_user, validate_access_to_resource +from fastapi import APIRouter, Depends, Request +from fastapi.security import HTTPBearer +from sqlalchemy.orm import Session + +from .dto import CreateInviteDTO, InviteResponseDTO +from .service import InviteService + +router = APIRouter(prefix="/invites", tags=["Invites"]) +_bearer = HTTPBearer() + +logger = logging.getLogger(__name__) + + +@router.post("", response_model=InviteResponseDTO, status_code=201) +async def create_invite( + data: CreateInviteDTO, + request: Request, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), + credentials=Depends(_bearer), +): + """ + Genera un token de invitación para que un usuario externo se registre. + Requiere permiso user.create. Usa el token del usuario actual para crear + el invite en el Hub — no requiere credenciales de hub_admin. + """ + validate_access_to_resource( + db, + data.company_id, + current_user, + required_permissions=["user.create"], + ) + + tenant_slug: str = current_user.get("tenant_slug") or "" + created_by: str = current_user.get("sub") or "" + + service = InviteService(db) + return await service.create_invite( + data=data, + created_by=created_by, + tenant_slug=tenant_slug, + user_access_token=credentials.credentials, + ) diff --git a/backend/api/v1/modules/core/invites/service.py b/backend/api/v1/modules/core/invites/service.py new file mode 100644 index 0000000..2935e6c --- /dev/null +++ b/backend/api/v1/modules/core/invites/service.py @@ -0,0 +1,282 @@ +"""Servicio de invitaciones de usuarios.""" + +import hashlib +import logging +import secrets +import ssl +from datetime import datetime, timedelta, timezone +from typing import Optional + +import aiosmtplib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from core.config import settings +from .dto import CreateInviteDTO, InviteResponseDTO, InviteValidationResult +from .models import InviteToken + +logger = logging.getLogger(__name__) + +INVITE_TTL_HOURS = 48 + + +def _hash_token(token_plain: str) -> str: + return hashlib.sha256(token_plain.encode()).hexdigest() + + +def _extract_token_from_url(url: str) -> Optional[str]: + """Extract invite_token query param from a URL string.""" + from urllib.parse import urlparse, parse_qs + parsed = urlparse(url) + params = parse_qs(parsed.query) + tokens = params.get("invite_token", []) + return tokens[0] if tokens else None + + +class InviteService: + def __init__(self, db: Session): + self.db = db + + async def create_invite( + self, + data: CreateInviteDTO, + created_by: str, + tenant_slug: str, + user_access_token: str = "", + ) -> InviteResponseDTO: + import httpx + from api.v1.modules.core.tenants.models import Tenant + + tenant = ( + self.db.query(Tenant) + .filter(Tenant.slug == tenant_slug, Tenant.is_active == True) + .first() + ) + if not tenant: + raise HTTPException(status_code=404, detail="Tenant no encontrado") + + token_plain = secrets.token_urlsafe(32) + token_hash = _hash_token(token_plain) + expires_at = datetime.now(timezone.utc) + timedelta(hours=INVITE_TTL_HOURS) + + from api.v1.modules.core.permissions.models import CompanyRole + + company_role = ( + self.db.query(CompanyRole) + .filter( + CompanyRole.id == data.role_id, + CompanyRole.company_id == data.company_id, + CompanyRole.is_active == True, + ) + .first() + ) + if not company_role: + raise HTTPException(status_code=404, detail="Rol no encontrado") + + # Crear invite en el Hub usando el token del usuario actual. + # El usuario debe tener role='admin' en su tenant dentro del Hub. + # No se requieren credenciales de hub_admin — sin secretos en el .env del cliente. + hub_invite_token: Optional[str] = None + invite_url: str = "" + if not user_access_token: + logger.error( + "[invite] user_access_token vacío — no se puede crear el invite en el Hub. " + "tenant=%s email=%s", + tenant_slug, + data.email, + ) + else: + try: + async with httpx.AsyncClient(timeout=10.0) as client: + hub_resp = await client.post( + f"{settings.HUB_URL}api/v1/hub/invites", + json={"email": str(data.email), "tenant_slug": tenant_slug}, + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + if hub_resp.status_code in (200, 201): + hub_data = hub_resp.json() + hub_invite_token = hub_data.get("invite_token") or _extract_token_from_url(hub_data.get("invite_url", "")) + invite_url = hub_data.get("invite_url", "") + else: + logger.error( + "[invite] Hub invite creation falló: status=%s body=%s tenant=%s email=%s", + hub_resp.status_code, + hub_resp.text[:300], + tenant_slug, + data.email, + ) + except Exception as exc: + logger.error("[invite] Hub invite creation excepción (non-blocking): %s", exc) + + # Fallback: URL del workspace (Hub) si la creación de invitación en Hub falló + if not invite_url: + hub_base = settings.HUB_URL.rstrip("/") + invite_url = ( + f"{hub_base}/register" + f"?invite_token={token_plain}" + f"&tenant={tenant_slug}" + f"&email={data.email}" + ) + + invite = InviteToken( + token_hash=token_hash, + tenant_slug=tenant_slug, + email=str(data.email), + role=company_role.code, + created_by=created_by, + expires_at=expires_at, + company_id=data.company_id, + hub_invite_token=hub_invite_token, + ) + self.db.add(invite) + self.db.commit() + self.db.refresh(invite) + + # Enviar email (best-effort) + try: + await self._send_invite_email( + to_email=str(data.email), + tenant_name=tenant.name, + invite_url=invite_url, + ) + except Exception as exc: + logger.warning( + "Invite email send failed (non-blocking): %s — invite_url=%s", + exc, + invite_url, + ) + + return InviteResponseDTO( + id=invite.id, + email=invite.email, + role=invite.role, + expires_at=invite.expires_at, + invite_url=invite_url, + created_at=invite.created_at, + ) + + def validate( + self, + token_plain: str, + tenant_slug: str, + email: Optional[str] = None, + ) -> InviteValidationResult: + """Valida el token sin consumirlo. Lanza 403 genérico por seguridad.""" + token_hash = _hash_token(token_plain) + now = datetime.now(timezone.utc) + + invite = ( + self.db.query(InviteToken) + .filter( + InviteToken.token_hash == token_hash, + InviteToken.tenant_slug == tenant_slug, + InviteToken.used_at.is_(None), + InviteToken.expires_at > now, + ) + .first() + ) + + if not invite: + raise HTTPException( + status_code=403, + detail="Token de invitación inválido o expirado", + ) + + if email and invite.email.lower() != email.lower(): + raise HTTPException( + status_code=403, + detail="Token de invitación inválido o expirado", + ) + + return InviteValidationResult( + email=invite.email, + role=invite.role, + invite_id=invite.id, + tenant_slug=invite.tenant_slug, + company_id=invite.company_id, + ) + + def consume_by_id(self, invite_id: int) -> None: + invite = self.db.query(InviteToken).filter(InviteToken.id == invite_id).first() + if invite: + invite.used_at = datetime.now(timezone.utc) + self.db.commit() + + async def _send_invite_email( + self, + to_email: str, + tenant_name: str, + invite_url: str, + ) -> None: + msg = MIMEMultipart("alternative") + msg["From"] = f"{settings.SMTP_FROM_NAME} <{settings.SMTP_USER}>" + msg["To"] = to_email + msg["Subject"] = f"Invitación para unirse a {tenant_name} en Mi Aplicación" + + html = f""" + + +
+
+

Mi Aplicación

+

Sistema de gestión aduanal

+
+
+

+ Te han invitado a {tenant_name} +

+

+ Has recibido una invitación para unirte a {tenant_name} + en Mi Aplicación. Haz clic en el botón para crear tu cuenta. +

+ +

+ Este enlace es válido por 48 horas y es de + un solo uso.
+ Si no esperabas esta invitación, puedes ignorar este correo. +

+
+

+ O copia este enlace en tu navegador:
+ {invite_url} +

+
+
+ + + """ + msg.attach(MIMEText(html, "html")) + + ssl_ctx = ssl.create_default_context() + ssl_ctx.check_hostname = False + ssl_ctx.verify_mode = ssl.CERT_NONE + + if settings.SMTP_PORT == 465: + async with aiosmtplib.SMTP( + hostname=settings.SMTP_HOST, + port=settings.SMTP_PORT, + use_tls=True, + tls_context=ssl_ctx, + ) as smtp: + await smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD) + await smtp.send_message(msg) + else: + async with aiosmtplib.SMTP( + hostname=settings.SMTP_HOST, + port=settings.SMTP_PORT, + tls_context=ssl_ctx, + ) as smtp: + await smtp.starttls(tls_context=ssl_ctx) + await smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD) + await smtp.send_message(msg) diff --git a/backend/api/v1/modules/core/licenses/__init__.py b/backend/api/v1/modules/core/licenses/__init__.py new file mode 100644 index 0000000..fffdb51 --- /dev/null +++ b/backend/api/v1/modules/core/licenses/__init__.py @@ -0,0 +1,7 @@ +""" +Módulo de Licenses +""" + +from .routes import router + +__all__ = ["router"] diff --git a/backend/api/v1/modules/core/licenses/dto.py b/backend/api/v1/modules/core/licenses/dto.py new file mode 100644 index 0000000..4636d70 --- /dev/null +++ b/backend/api/v1/modules/core/licenses/dto.py @@ -0,0 +1,156 @@ +""" +DTOs para módulo de licencias +""" + +from datetime import datetime +from enum import Enum +from typing import Optional + +from pydantic import BaseModel, Field + + +class LicensePlanDTO(str, Enum): + """Planes de licencia""" + + FREE = "free" + BASIC = "basic" + PROFESSIONAL = "professional" + ENTERPRISE = "enterprise" + + +class LicenseStatusDTO(str, Enum): + """Estados de licencia""" + + ACTIVE = "active" + EXPIRED = "expired" + SUSPENDED = "suspended" + PENDING = "pending" + CANCELLED = "cancelled" + + +class LicenseCreateDTO(BaseModel): + """DTO para crear una nueva licencia""" + + tenant_id: int = Field(..., description="ID del tenant") + plan: LicensePlanDTO = Field(..., description="Plan de licencia") + max_users: int = Field(default=5, ge=1, description="Número máximo de usuarios") + max_storage_gb: int = Field( + default=10, ge=1, description="Almacenamiento máximo en GB" + ) + max_monthly_operations: int = Field( + default=1000, ge=1, description="Operaciones mensuales máximas" + ) + + feature_api_access: bool = Field(default=True) + feature_advanced_reports: bool = Field(default=False) + feature_integrations: bool = Field(default=False) + feature_dedicated_support: bool = Field(default=False) + + starts_at: datetime = Field(..., description="Fecha de inicio de vigencia") + expires_at: datetime = Field(..., description="Fecha de expiración") + + class Config: + json_schema_extra = { + "example": { + "tenant_id": 1, + "plan": "professional", + "max_users": 20, + "max_storage_gb": 100, + "max_monthly_operations": 10000, + "feature_api_access": True, + "feature_advanced_reports": True, + "feature_integrations": True, + "feature_dedicated_support": False, + "starts_at": "2025-01-01T00:00:00Z", + "expires_at": "2025-12-31T23:59:59Z", + } + } + + +class LicenseUpdateDTO(BaseModel): + """DTO para actualizar una licencia""" + + plan: Optional[LicensePlanDTO] = None + status: Optional[LicenseStatusDTO] = None + max_users: Optional[int] = Field(None, ge=1) + max_storage_gb: Optional[int] = Field(None, ge=1) + max_monthly_operations: Optional[int] = Field(None, ge=1) + + feature_api_access: Optional[bool] = None + feature_advanced_reports: Optional[bool] = None + feature_integrations: Optional[bool] = None + feature_dedicated_support: Optional[bool] = None + + expires_at: Optional[datetime] = None + + +class LicenseResponseDTO(BaseModel): + """DTO para respuesta de licencia""" + + id: int + tenant_id: int + plan: LicensePlanDTO + status: LicenseStatusDTO + + max_users: int + max_storage_gb: int + max_monthly_operations: int + + feature_api_access: bool + feature_advanced_reports: bool + feature_integrations: bool + feature_dedicated_support: bool + + starts_at: datetime + expires_at: datetime + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class LicenseValidationResponseDTO(BaseModel): + """DTO para respuesta de validación de licencia""" + + is_valid: bool + status: LicenseStatusDTO + plan: LicensePlanDTO + expires_at: datetime + reason: Optional[str] = None + + class Config: + json_schema_extra = { + "example": { + "is_valid": True, + "status": "active", + "plan": "professional", + "expires_at": "2025-12-31T23:59:59Z", + "reason": None, + } + } + + +class LicenseUsageResponseDTO(BaseModel): + """DTO para respuesta de uso de licencia""" + + tenant_id: int + period_start: datetime + period_end: datetime + active_users: int + storage_used_gb: int + operations_count: int + api_calls_count: int + + # Límites actuales + max_users: int + max_storage_gb: int + max_monthly_operations: int + + # Porcentajes de uso + users_usage_percent: float + storage_usage_percent: float + operations_usage_percent: float + + class Config: + from_attributes = True diff --git a/backend/api/v1/modules/core/licenses/models.py b/backend/api/v1/modules/core/licenses/models.py new file mode 100644 index 0000000..e08a414 --- /dev/null +++ b/backend/api/v1/modules/core/licenses/models.py @@ -0,0 +1,102 @@ +""" +Modelos ORM para gestión de licencias +""" + +import enum + +from api.v1.common.base_models import TimestampMixin +from core.database import Base +from sqlalchemy import Boolean, Column, DateTime +from sqlalchemy import Enum as SQLEnum +from sqlalchemy import ForeignKey, Integer + + +class LicensePlan(enum.Enum): + """Planes de licencia disponibles""" + + FREE = "free" + BASIC = "basic" + PROFESSIONAL = "professional" + ENTERPRISE = "enterprise" + + +class LicenseStatus(enum.Enum): + """Estados de licencia""" + + ACTIVE = "active" + EXPIRED = "expired" + SUSPENDED = "suspended" + PENDING = "pending" + CANCELLED = "cancelled" + + +class License(Base, TimestampMixin): + """ + Modelo de Licencia - Control de planes y límites por tenant + """ + + __tablename__ = "licenses" + __table_args__ = {"schema": "core"} + + id = Column(Integer, primary_key=True, index=True) + tenant_id = Column( + Integer, ForeignKey("core.tenants.id"), nullable=False, unique=True, index=True + ) + + # Plan y características + plan = Column( + SQLEnum(LicensePlan), + default=LicensePlan.FREE, + server_default="FREE", + nullable=False, + ) + status = Column( + SQLEnum(LicenseStatus), + default=LicenseStatus.PENDING, + server_default="PENDING", + nullable=False, + ) + + # Límites del plan + max_users = Column(Integer, server_default="5", nullable=False) + max_storage_gb = Column(Integer, server_default="10", nullable=False) + max_monthly_operations = Column(Integer, server_default="1000", nullable=False) + + # Features habilitadas (booleans) + feature_api_access = Column(Boolean, default=True, server_default="true") + feature_advanced_reports = Column(Boolean, default=False, server_default="false") + feature_integrations = Column(Boolean, default=False, server_default="false") + feature_dedicated_support = Column(Boolean, default=False, server_default="false") + + # Vigencia + starts_at = Column(DateTime(timezone=True), nullable=False) + expires_at = Column(DateTime(timezone=True), nullable=False) + + def __repr__(self): + return f"" + + +class LicenseUsage(Base, TimestampMixin): + """ + Modelo para tracking de uso de licencia + """ + + __tablename__ = "license_usage" + __table_args__ = {"schema": "core"} + + id = Column(Integer, primary_key=True, index=True) + tenant_id = Column( + Integer, ForeignKey("core.tenants.id"), nullable=False, index=True + ) + + # Métricas de uso + period_start = Column(DateTime(timezone=True), nullable=False) + period_end = Column(DateTime(timezone=True), nullable=False) + + active_users = Column(Integer, default=0, server_default="0") + storage_used_gb = Column(Integer, default=0, server_default="0") + operations_count = Column(Integer, default=0, server_default="0") + api_calls_count = Column(Integer, default=0, server_default="0") + + def __repr__(self): + return f"" diff --git a/backend/api/v1/modules/core/licenses/routes.py b/backend/api/v1/modules/core/licenses/routes.py new file mode 100644 index 0000000..d88b4ae --- /dev/null +++ b/backend/api/v1/modules/core/licenses/routes.py @@ -0,0 +1,119 @@ +""" +Endpoints API para gestión de licencias +""" + +from core.database import get_core_db +from core.security import get_current_user, has_role +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlalchemy.orm import Session + +from .dto import ( + LicenseCreateDTO, + LicenseResponseDTO, + LicenseUpdateDTO, + LicenseUsageResponseDTO, + LicenseValidationResponseDTO, +) +from .service import LicenseService + +router = APIRouter(prefix="/licenses") + + +@router.post("/", response_model=LicenseResponseDTO, status_code=201) +async def create_license( + license_data: LicenseCreateDTO, + db: Session = Depends(get_core_db), + current_user: dict = Depends(has_role("admin")), +): + """ + Crea una nueva licencia para un tenant + + Requiere rol: admin + """ + service = LicenseService(db) + return service.create_license(license_data) + + +@router.get("/tenant/{tenant_id}", response_model=LicenseResponseDTO) +async def get_license_by_tenant( + tenant_id: int, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Obtiene la licencia de un tenant específico + """ + service = LicenseService(db) + license = service.get_license_by_tenant(tenant_id) + if not license: + raise HTTPException(status_code=404, detail="License not found") + return license + + +@router.put("/tenant/{tenant_id}", response_model=LicenseResponseDTO) +async def update_license( + tenant_id: int, + license_data: LicenseUpdateDTO, + db: Session = Depends(get_core_db), + current_user: dict = Depends(has_role("admin")), +): + """ + Actualiza la licencia de un tenant + + Requiere rol: admin + """ + service = LicenseService(db) + license = service.update_license(tenant_id, license_data) + if not license: + raise HTTPException(status_code=404, detail="License not found") + return license + + +@router.get("/validate/{tenant_id}", response_model=LicenseValidationResponseDTO) +async def validate_license( + tenant_id: int, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Valida si la licencia de un tenant está activa y vigente + """ + service = LicenseService(db) + validation = service.validate_license(tenant_id) + return LicenseValidationResponseDTO(**validation) + + +@router.get("/usage/{tenant_id}", response_model=LicenseUsageResponseDTO) +async def get_license_usage( + tenant_id: int, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Obtiene el uso actual de la licencia de un tenant + """ + service = LicenseService(db) + usage = service.get_usage(tenant_id) + if not usage: + raise HTTPException(status_code=404, detail="License not found") + return usage + + +@router.get("/my-license", response_model=LicenseResponseDTO) +async def get_my_license( + request: Request, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Obtiene la licencia del tenant del usuario actual + """ + tenant_id = getattr(request.state, "tenant_id", None) + if not tenant_id: + raise HTTPException(status_code=400, detail="Tenant ID not found in request") + + service = LicenseService(db) + license = service.get_license_by_tenant(tenant_id) + if not license: + raise HTTPException(status_code=404, detail="License not found") + return license diff --git a/backend/api/v1/modules/core/licenses/service.py b/backend/api/v1/modules/core/licenses/service.py new file mode 100644 index 0000000..0c3933a --- /dev/null +++ b/backend/api/v1/modules/core/licenses/service.py @@ -0,0 +1,260 @@ +""" +Servicio de lógica de negocio para licencias +""" + +import logging +from datetime import datetime, timezone +from typing import Optional + +from fastapi import HTTPException +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from .dto import ( + LicenseCreateDTO, + LicenseResponseDTO, + LicenseUpdateDTO, + LicenseUsageResponseDTO, +) +from .models import License, LicensePlan, LicenseStatus, LicenseUsage + +logger = logging.getLogger(__name__) + + +class LicenseService: + """Servicio para gestión de licencias""" + + def __init__(self, db: Session): + self.db = db + + def create_license(self, license_data: LicenseCreateDTO) -> LicenseResponseDTO: + """ + Crea una nueva licencia para un tenant + + Args: + license_data: Datos de la licencia + + Returns: + LicenseResponseDTO + + Raises: + HTTPException: Si el tenant ya tiene licencia o hay error + """ + try: + # Verificar que el tenant no tenga ya una licencia + existing = ( + self.db.query(License) + .filter(License.tenant_id == license_data.tenant_id) + .first() + ) + + if existing: + raise HTTPException( + status_code=400, + detail=f"Tenant {license_data.tenant_id} already has a license", + ) + + # Crear licencia + db_license = License( + tenant_id=license_data.tenant_id, + plan=LicensePlan(license_data.plan.value), + status=LicenseStatus.ACTIVE, + max_users=license_data.max_users, + max_storage_gb=license_data.max_storage_gb, + max_monthly_operations=license_data.max_monthly_operations, + feature_api_access=license_data.feature_api_access, + feature_advanced_reports=license_data.feature_advanced_reports, + feature_integrations=license_data.feature_integrations, + feature_dedicated_support=license_data.feature_dedicated_support, + starts_at=license_data.starts_at, + expires_at=license_data.expires_at, + ) + + self.db.add(db_license) + self.db.commit() + self.db.refresh(db_license) + + return LicenseResponseDTO.model_validate(db_license) + + except IntegrityError as e: + self.db.rollback() + logger.error(f"IntegrityError creating license: {str(e)}") + raise HTTPException(status_code=400, detail="Database integrity error") + except HTTPException: + raise + except Exception as e: + self.db.rollback() + logger.error(f"Error creating license: {str(e)}") + raise HTTPException(status_code=500, detail="Error creating license") + + def get_license_by_tenant(self, tenant_id: int) -> Optional[LicenseResponseDTO]: + """ + Obtiene la licencia de un tenant + + Args: + tenant_id: ID del tenant + + Returns: + LicenseResponseDTO o None si no existe + """ + license = self.db.query(License).filter(License.tenant_id == tenant_id).first() + if not license: + return None + return LicenseResponseDTO.model_validate(license) + + def update_license( + self, tenant_id: int, license_data: LicenseUpdateDTO + ) -> Optional[LicenseResponseDTO]: + """ + Actualiza una licencia + + Args: + tenant_id: ID del tenant + license_data: Datos a actualizar + + Returns: + LicenseResponseDTO actualizado o None si no existe + """ + license = self.db.query(License).filter(License.tenant_id == tenant_id).first() + if not license: + return None + + # Actualizar campos proporcionados + update_data = license_data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + if field in ["plan", "status"]: + # Convertir enums + value = LicensePlan(value) if field == "plan" else LicenseStatus(value) + setattr(license, field, value) + + try: + self.db.commit() + self.db.refresh(license) + return LicenseResponseDTO.model_validate(license) + except Exception as e: + self.db.rollback() + logger.error(f"Error updating license for tenant {tenant_id}: {str(e)}") + raise HTTPException(status_code=500, detail="Error updating license") + + def validate_license(self, tenant_id: int) -> dict: + """ + Valida si la licencia de un tenant está activa y vigente + + Args: + tenant_id: ID del tenant + + Returns: + Dict con información de validación + """ + license = self.db.query(License).filter(License.tenant_id == tenant_id).first() + + if not license: + return { + "is_valid": False, + "status": "not_found", + "plan": None, + "expires_at": None, + "reason": "License not found", + } + + now = datetime.now(timezone.utc) + + # Verificar estado + if license.status != LicenseStatus.ACTIVE: + return { + "is_valid": False, + "status": license.status.value, + "plan": license.plan.value, + "expires_at": license.expires_at, + "reason": f"License status is {license.status.value}", + } + + # Verificar vigencia + if license.expires_at < now: + # Auto-actualizar a expirada + license.status = LicenseStatus.EXPIRED + self.db.commit() + + return { + "is_valid": False, + "status": "expired", + "plan": license.plan.value, + "expires_at": license.expires_at, + "reason": "License has expired", + } + + # Licencia válida + return { + "is_valid": True, + "status": license.status.value, + "plan": license.plan.value, + "expires_at": license.expires_at, + "reason": None, + } + + def get_usage(self, tenant_id: int) -> Optional[LicenseUsageResponseDTO]: + """ + Obtiene el uso actual de la licencia de un tenant + + Args: + tenant_id: ID del tenant + + Returns: + LicenseUsageResponseDTO o None + """ + license = self.db.query(License).filter(License.tenant_id == tenant_id).first() + if not license: + return None + + # Obtener último registro de uso + usage = ( + self.db.query(LicenseUsage) + .filter(LicenseUsage.tenant_id == tenant_id) + .order_by(LicenseUsage.created_at.desc()) + .first() + ) + + if not usage: + # Crear registro inicial si no existe + usage = LicenseUsage( + tenant_id=tenant_id, + period_start=datetime.now(timezone.utc), + period_end=datetime.now(timezone.utc), + active_users=0, + storage_used_gb=0, + operations_count=0, + api_calls_count=0, + ) + + # Calcular porcentajes + users_usage = ( + (usage.active_users / license.max_users * 100) + if license.max_users > 0 + else 0 + ) + storage_usage = ( + (usage.storage_used_gb / license.max_storage_gb * 100) + if license.max_storage_gb > 0 + else 0 + ) + operations_usage = ( + (usage.operations_count / license.max_monthly_operations * 100) + if license.max_monthly_operations > 0 + else 0 + ) + + return LicenseUsageResponseDTO( + tenant_id=tenant_id, + period_start=usage.period_start, + period_end=usage.period_end, + active_users=usage.active_users, + storage_used_gb=usage.storage_used_gb, + operations_count=usage.operations_count, + api_calls_count=usage.api_calls_count, + max_users=license.max_users, + max_storage_gb=license.max_storage_gb, + max_monthly_operations=license.max_monthly_operations, + users_usage_percent=round(users_usage, 2), + storage_usage_percent=round(storage_usage, 2), + operations_usage_percent=round(operations_usage, 2), + ) diff --git a/backend/api/v1/modules/core/permissions/README.md b/backend/api/v1/modules/core/permissions/README.md new file mode 100644 index 0000000..d259bca --- /dev/null +++ b/backend/api/v1/modules/core/permissions/README.md @@ -0,0 +1,324 @@ +# Módulo de Permisos Multi-Tenant + +Sistema completo de permisos granulares para aplicaciones multi-tenant con FastAPI y SQLAlchemy. + +## 📁 Estructura del Módulo + +``` +backend/api/v1/modules/core/permissions/ +├── __init__.py # Exports del módulo +├── models.py # Modelos SQLAlchemy +├── service.py # Lógica de negocio +├── dependencies.py # Dependencias FastAPI +├── schemas.py # Modelos Pydantic (request/response) +└── routes.py # Endpoints de la API +``` + +## 🎯 Componentes + +### **models.py** + +Define los modelos de base de datos: + +- `Permission` - Permisos del sistema (ej: "invoice.view", "invoice.edit") +- `ClientRole` - Roles personalizados por cliente +- `RolePermission` - Relación roles-permisos +- `UserClientRole` - Asignación usuario-rol-cliente +- `UserClientPermission` - Permisos directos por usuario + +### **service.py** + +Contiene la clase `PermissionService` con métodos: + +- `get_user_permissions()` - Obtiene todos los permisos de un usuario +- `has_permission()` - Verifica un permiso específico +- `has_all_permissions()` - Verifica múltiples permisos (AND) +- `has_any_permission()` - Verifica múltiples permisos (OR) +- `assign_role_to_user()` - Asigna roles a usuarios +- `grant_direct_permission()` - Concede permisos directos + +### **dependencies.py** + +Dependencias para proteger rutas: + +- `PermissionChecker` - Clase para verificar múltiples permisos +- `RequirePermission` - Clase para verificar un solo permiso +- `get_client_id()` - Extrae el ID del cliente del header +- `get_permission_service()` - Proporciona instancia del servicio +- `get_current_user_permissions()` - Devuelve permisos del usuario + +### **schemas.py** + +Modelos Pydantic para request/response: + +- Responses: `PermissionResponse`, `ClientRoleResponse`, `UserPermissionsResponse`, etc. +- Requests: `AssignRoleRequest`, `GrantPermissionRequest`, `CreateRoleRequest`, etc. + +### **routes.py** + +Endpoints de la API: + +- `GET /permissions/me` - Permisos del usuario actual +- `GET /permissions/available` - Lista todos los permisos +- `GET /permissions/roles` - Lista roles del cliente +- `POST /permissions/roles` - Crea un rol +- `POST /permissions/assign-role` - Asigna rol a usuario +- `POST /permissions/grant-permission` - Concede permiso directo +- Ejemplos de rutas protegidas + +## 🚀 Uso Rápido + +### Importar el módulo + +```python +from api.v1.modules.core.permissions import ( + Permission, + ClientRole, + PermissionService, + PermissionChecker, + RequirePermission, + router +) +``` + +### Registrar las rutas + +```python +# En backend/api/v1/router.py +from api.v1.modules.core.permissions import router as permissions_router + +api_router = APIRouter() +api_router.include_router(permissions_router) +``` + +### Proteger una ruta con permiso único + +```python +from fastapi import APIRouter, Depends +from api.v1.modules.core.permissions import RequirePermission + +router = APIRouter() + +@router.get("/invoices") +async def list_invoices( + _: None = Depends(RequirePermission("invoice.view")) +): + return {"invoices": [...]} +``` + +### Proteger con múltiples permisos + +```python +from api.v1.modules.core.permissions import PermissionChecker + +@router.post("/invoices") +async def create_invoice( + _: None = Depends(PermissionChecker( + ["invoice.view", "invoice.create"], + require_all=True # Requiere TODOS + )) +): + return {"created": True} +``` + +### Usar permisos en la lógica + +```python +from api.v1.modules.core.permissions import get_current_user_permissions + +@router.get("/dashboard") +async def dashboard( + permissions: set = Depends(get_current_user_permissions) +): + widgets = [] + + if "invoice.view" in permissions: + widgets.append({"type": "invoices", "data": [...]}) + + return {"widgets": widgets} +``` + +## 📊 Base de Datos + +### Ejecutar migración + +```bash +cd backend +alembic upgrade head +``` + +Esto crea las tablas y permisos iniciales: + +- **invoice.*** - view, create, edit, delete, approve +- **user.*** - view, create, edit, delete +- **report.*** - financial.view, admin.view, export +- **roles.*** - view, create, edit, delete, assign +- **permissions.*** - view, grant + +## 🔐 Flujo de Autenticación + +1. Usuario hace request con token JWT de Keycloak +2. Header `X-Client-ID` indica el cliente/tenant +3. Sistema extrae `user_id` del token +4. Consulta permisos del usuario en ese cliente +5. Valida si tiene el permiso requerido +6. Devuelve 200 OK o 403 Forbidden + +## 💡 Ejemplos Prácticos + +### Crear un rol personalizado + +```python +from api.v1.modules.core.permissions import PermissionService +from core.database import get_db + +db = next(get_db()) +service = PermissionService(db) + +# Crear rol +role = ClientRole( + client_id=1, + name="Contador", + code="accountant", + description="Acceso a módulo contable" +) +db.add(role) +db.commit() +``` + +### Asignar permisos a un rol + +```python +from api.v1.modules.core.permissions.models import RolePermission + +# Obtener permisos de facturación +invoice_perms = db.query(Permission).filter( + Permission.module == "invoice" +).all() + +# Asignar al rol +for perm in invoice_perms: + role_perm = RolePermission( + client_role_id=role.id, + permission_id=perm.id + ) + db.add(role_perm) + +db.commit() +``` + +### Asignar rol a usuario + +```python +service.assign_role_to_user( + user_id="user-uuid-from-keycloak", + client_id=1, + role_id=role.id, + assigned_by="admin-uuid" +) +``` + +### Conceder permiso temporal + +```python +from datetime import datetime, timedelta + +service.grant_direct_permission( + user_id="user-uuid", + client_id=1, + permission_code="invoice.delete", + assigned_by="admin-uuid", + expires_at=datetime.utcnow() + timedelta(days=7) +) +``` + +## ⚡ Optimización de Rendimiento + +### 1. Caché con Redis + +```python +import redis +from functools import lru_cache + +redis_client = redis.Redis(host='localhost', port=6379) + +def get_cached_permissions(user_id: str, client_id: int) -> set: + cache_key = f"perms:{user_id}:{client_id}" + + cached = redis_client.get(cache_key) + if cached: + return set(cached.decode().split(',')) + + # Consultar DB + service = PermissionService(db) + permissions = service.get_user_permissions(user_id, client_id) + + # Cachear por 5 minutos + redis_client.setex(cache_key, 300, ','.join(permissions)) + + return permissions +``` + +### 2. Índices de Base de Datos + +Ya están definidos en los modelos: + +- Índices compuestos para consultas eficientes +- Índices únicos para prevenir duplicados +- Índices en foreign keys + +### 3. Query Optimization + +El servicio usa JOINs eficientes en lugar de N+1 queries. + +## 🧪 Testing + +```python +import pytest +from api.v1.modules.core.permissions import PermissionService +from api.v1.modules.core.permissions.models import Permission, ClientRole + +def test_user_has_permission_from_role(db_session): + # Setup + perm = Permission(code="invoice.view", module="invoice", action="view") + db_session.add(perm) + + role = ClientRole(client_id=1, code="viewer", name="Viewer") + db_session.add(role) + db_session.commit() + + # Test + service = PermissionService(db_session) + assert service.has_permission("user-123", 1, "invoice.view") +``` + +## 📝 Notas Importantes + +- **Client ID**: Por defecto se obtiene del header `X-Client-ID`, pero puede adaptarse a subdominios o JWT +- **User ID**: Se extrae del campo `sub` del token JWT de Keycloak +- **Permisos Directos**: Pueden revocar permisos heredados de roles (`is_granted=False`) +- **Soft Delete**: Los roles y permisos se desactivan (`is_active=False`) en lugar de eliminarse + +## 🔗 Integración con Keycloak + +Los roles globales de Keycloak pueden coexistir con los roles locales: + +```python +@router.get("/protected") +async def protected_route( + current_user: dict = Depends(get_current_user), + permissions: set = Depends(get_current_user_permissions) +): + # Verificar rol global de Keycloak + keycloak_roles = current_user.get("realm_access", {}).get("roles", []) + + if "super_admin" in keycloak_roles: + # Super admin tiene acceso total + return {"access": "granted", "level": "global"} + + # Verificar permisos a nivel de cliente + if "invoice.view" in permissions: + return {"access": "granted", "level": "client"} + + raise HTTPException(403, "No access") +``` diff --git a/backend/api/v1/modules/core/permissions/__init__.py b/backend/api/v1/modules/core/permissions/__init__.py new file mode 100644 index 0000000..06a6a50 --- /dev/null +++ b/backend/api/v1/modules/core/permissions/__init__.py @@ -0,0 +1,38 @@ +""" +Módulo de permisos multi-tenant. +Proporciona modelos, servicios y rutas para gestión de permisos granulares por companye. +""" + +from .models import ( + Permission, + CompanyRole, + RolePermission, + UserCompanyRole, + UserCompanyPermission, +) +from .service import PermissionService +from .dependencies import ( + PermissionChecker, + RequirePermission, + get_permission_service, + get_current_user_permissions, +) +from .routes import router + +__all__ = [ + # Models + "Permission", + "CompanyRole", + "RolePermission", + "UserCompanyRole", + "UserCompanyPermission", + # Service + "PermissionService", + # Dependencies + "PermissionChecker", + "RequirePermission", + "get_permission_service", + "get_current_user_permissions", + # Router + "router", +] diff --git a/backend/api/v1/modules/core/permissions/cache.py b/backend/api/v1/modules/core/permissions/cache.py new file mode 100644 index 0000000..c709b5d --- /dev/null +++ b/backend/api/v1/modules/core/permissions/cache.py @@ -0,0 +1,205 @@ +import logging +import os +from typing import Optional, Set, Iterable + +from core.config import settings + +try: + import redis # type: ignore +except Exception: # pragma: no cover - redis is optional in some envs + redis = None # type: ignore + + +logger = logging.getLogger(__name__) + + +class PermissionCache: + """ + Caché de permisos basada en Valkey/Redis. + + - Clave por combinación (tenant_id, company_id, user_id) + - Guarda el set de códigos de permiso como string CSV + - TTL controlado por configuración (`PERMISSION_CACHE_TTL_SECONDS`) + + Todas las operaciones fallan en modo silencioso para no afectar el flujo + principal de la aplicación si Redis/Valkey no está disponible. + """ + + KEY_PREFIX = "permissions:v1" + + def __init__(self, client: "redis.Redis | None" = None) -> None: # type: ignore[name-defined] + self.ttl_seconds = int(getattr(settings, "PERMISSION_CACHE_TTL_SECONDS", 300) or 300) + enabled_flag = bool(getattr(settings, "PERMISSION_CACHE_ENABLED", True)) + + # Si redis no está instalado, deshabilitar caché + if redis is None: + self._client = None + self.enabled = False + return + + if client is not None: + self._client = client + self.enabled = enabled_flag + return + + url = ( + os.getenv("VALKEY_URL") + or os.getenv("REDIS_URL") + or getattr(settings, "VALKEY_URL", "redis://valkey:6379/0") + ) + + try: + # decode_responses=True para trabajar con str en lugar de bytes + self._client = redis.Redis.from_url(url, decode_responses=True) + # Probar conexión rápida (no crítico si falla) + if enabled_flag: + try: + self._client.ping() + self.enabled = True + except Exception: + logger.warning( + "permission_cache_ping_failed", + extra={"url": url}, + ) + self.enabled = False + else: + self.enabled = False + except Exception as exc: + logger.warning( + "permission_cache_init_failed", + extra={"url": url, "error": str(exc)}, + ) + self._client = None + self.enabled = False + + # ------------------------------------------------------------------ + # Helpers de clave + # ------------------------------------------------------------------ + def build_permissions_key( + self, + tenant_id: Optional[int], + company_id: int, + user_id: str, + ) -> str: + """ + Construye la clave única de caché para un usuario en una compañía. + """ + tenant_part = str(tenant_id) if tenant_id is not None else "global" + return f"{self.KEY_PREFIX}:tenant:{tenant_part}:company:{company_id}:user:{user_id}" + + # ------------------------------------------------------------------ + # Operaciones de lectura/escritura + # ------------------------------------------------------------------ + def get_permissions( + self, + cache_key: str, + **context: object, + ) -> Optional[Set[str]]: + """ + Obtiene el set de permisos desde caché. + + Devuelve: + - set[str] si hay caché válido + - None si no hay entrada o si la caché está deshabilitada + """ + if not self.enabled or not self._client: + return None + + try: + raw = self._client.get(cache_key) + if raw is None: + return None + if not raw: + return set() + return set(raw.split(",")) + except Exception as exc: + logger.warning( + "permission_cache_get_failed", + extra={"cache_key": cache_key, "error": str(exc), **context}, + ) + return None + + def set_permissions( + self, + cache_key: str, + permissions: Iterable[str], + **context: object, + ) -> None: + """ + Escribe el set de permisos en caché con TTL. + """ + if not self.enabled or not self._client: + return + + try: + value = ",".join(sorted(set(permissions))) + self._client.setex(cache_key, self.ttl_seconds, value) + except Exception as exc: + logger.warning( + "permission_cache_set_failed", + extra={"cache_key": cache_key, "error": str(exc), **context}, + ) + + # ------------------------------------------------------------------ + # Invalidaciones + # ------------------------------------------------------------------ + def _delete_pattern(self, pattern: str) -> None: + """ + Elimina todas las llaves que coincidan con un patrón. + """ + if not self.enabled or not self._client: + return + + try: + # scan_iter evita bloquear Redis en grandes keyspaces + keys = list(self._client.scan_iter(match=pattern)) + if keys: + self._client.delete(*keys) + except Exception as exc: + logger.warning( + "permission_cache_delete_pattern_failed", + extra={"pattern": pattern, "error": str(exc)}, + ) + + def invalidate_user( + self, + tenant_id: Optional[int], + company_id: int, + user_id: str, + ) -> None: + """ + Invalida el caché de permisos para un usuario específico. + """ + if not self.enabled or not self._client: + return + + cache_key = self.build_permissions_key(tenant_id, company_id, user_id) + try: + self._client.delete(cache_key) + except Exception as exc: + logger.warning( + "permission_cache_invalidate_user_failed", + extra={ + "cache_key": cache_key, + "tenant_id": tenant_id, + "company_id": company_id, + "user_id": user_id, + "error": str(exc), + }, + ) + + def invalidate_company(self, company_id: int) -> None: + """ + Invalida el caché de permisos para todos los usuarios de una compañía. + """ + pattern = f"{self.KEY_PREFIX}:tenant:*:company:{company_id}:user:*" + self._delete_pattern(pattern) + + def invalidate_all(self) -> None: + """ + Elimina TODAS las entradas del caché de permisos. + Úsese con precaución (ej. cleanup_cli). + """ + pattern = f"{self.KEY_PREFIX}:*" + self._delete_pattern(pattern) + diff --git a/backend/api/v1/modules/core/permissions/cleanup_cli.py b/backend/api/v1/modules/core/permissions/cleanup_cli.py new file mode 100644 index 0000000..958464b --- /dev/null +++ b/backend/api/v1/modules/core/permissions/cleanup_cli.py @@ -0,0 +1,68 @@ +""" +Script CLI para LIMPIEZA TOTAL del sistema de permisos. +Borra todos los roles, asignaciones y el catálogo de permisos. +Úselo con precaución. + +Uso: +docker exec -it python3 -m api.v1.modules.core.permissions.cleanup_cli +""" + +import sys +import os +import logging +from sqlalchemy import text + +# Configurar logging +logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") +logger = logging.getLogger(__name__) + +# Asegurar que el backend esté en el path +sys.path.append(os.path.abspath(".")) +sys.path.append(os.path.abspath("backend")) + +from core.database import CoreSessionLocal +from api.v1.modules.core.permissions.cache import PermissionCache + +def run_cleanup(): + """Ejecuta el borrado de tablas en orden de dependencias.""" + logger.warning("INICIANDO LIMPIEZA TOTAL DE PERMISOS Y ROLES...") + + db = CoreSessionLocal() + try: + # 1. Borrar asignaciones directas de permisos a usuarios + logger.info("Borrando asignaciones directas de usuario...") + db.execute(text("DELETE FROM core.user_company_permissions")) + + # 2. Borrar relación entre roles y permisos + logger.info("Borrando mapeo de roles y permisos...") + db.execute(text("DELETE FROM core.role_permissions")) + + # 3. Borrar asignación de roles a usuarios + logger.info("Borrando asignación de roles a usuarios...") + db.execute(text("DELETE FROM core.user_company_roles")) + + # 4. Borrar los roles mismos + logger.info("Borrando el catálogo de roles...") + db.execute(text("DELETE FROM core.company_roles")) + + # 5. Borrar el catálogo base de permisos + logger.info("Borrando el catálogo base de permisos...") + db.execute(text("DELETE FROM core.permissions")) + + db.commit() + # Limpiar también el caché de permisos en Valkey + PermissionCache().invalidate_all() + logger.info("=" * 40) + logger.info("LIMPIEZA COMPLETADA CON ÉXITO") + logger.info("El sistema de permisos está ahora en blanco.") + logger.info("=" * 40) + + except Exception as e: + db.rollback() + logger.error(f"Error crítico durante la limpieza: {e}") + sys.exit(1) + finally: + db.close() + +if __name__ == "__main__": + run_cleanup() diff --git a/backend/api/v1/modules/core/permissions/dependencies.py b/backend/api/v1/modules/core/permissions/dependencies.py new file mode 100644 index 0000000..ecd9c19 --- /dev/null +++ b/backend/api/v1/modules/core/permissions/dependencies.py @@ -0,0 +1,205 @@ +""" +Dependencias de FastAPI para verificación de permisos multi-tenant. +Proporciona decoradores y funciones para proteger rutas con permisos específicos. +""" + +from typing import List, Optional, Callable +from fastapi import Depends, HTTPException, status, Header +from sqlalchemy.orm import Session +from functools import wraps +from core.database import get_core_db +from core.security import get_current_user # Asumiendo que existe esta función +from .service import PermissionService + +# Dependencia para obtener el servicio de permisos +def get_permission_service(db: Session = Depends(get_core_db)) -> PermissionService: + """ + Crea una instancia del servicio de permisos con la sesión de base de datos. + """ + return PermissionService(db) + + +# Clase para verificación de permisos (puede usarse como dependencia) +class PermissionChecker: + """ + Verificador de permisos que puede usarse como dependencia de FastAPI. + + Ejemplo de uso: + @app.get("/invoices") + async def list_invoices( + _: None = Depends(PermissionChecker(["invoice.view"])) + ): + return {"invoices": [...]} + """ + + def __init__(self, required_permissions: List[str], require_all: bool = True): + """ + Args: + required_permissions: Lista de permisos requeridos + require_all: Si True, requiere TODOS los permisos. + Si False, requiere AL MENOS UNO. + """ + self.required_permissions = required_permissions + self.require_all = require_all + + async def __call__( + self, + company_id: int, + current_user: dict = Depends(get_current_user), + permission_service: PermissionService = Depends(get_permission_service), + ): + """ + Verifica que el usuario tenga los permisos requeridos. + + Lanza HTTPException 403 si no tiene permisos. + """ + user_id = current_user.get("sub") or current_user.get("id") + + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User ID not found in token", + ) + + # Verificar permisos sobre la compañía + if self.require_all: + has_access = permission_service.has_all_permissions( + user_id=user_id, + company_id=company_id, + permission_codes=self.required_permissions, + ) + else: + has_access = permission_service.has_any_permission( + user_id=user_id, + company_id=company_id, + permission_codes=self.required_permissions, + ) + + if not has_access: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Missing required permissions: {', '.join(self.required_permissions)}", + ) + + return True + + +# Función alternativa para verificar un solo permiso +class RequirePermission: + """ + Verificador simple para un único permiso. + + Ejemplo: + @app.post("/invoices") + async def create_invoice( + _: None = Depends(RequirePermission("invoice.create")) + ): + return {"created": True} + """ + + def __init__(self, permission_code: str): + self.permission_code = permission_code + + async def __call__( + self, + company_id: int, + current_user: dict = Depends(get_current_user), + permission_service: PermissionService = Depends(get_permission_service), + ): + user_id = current_user.get("sub") or current_user.get("id") + + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User ID not found in token", + ) + + has_permission = permission_service.has_permission( + user_id=user_id, + company_id=company_id, + permission_code=self.permission_code, + ) + + if not has_permission: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Missing required permission: {self.permission_code}", + ) + + return True + + +# Decorador personalizado para aplicar a funciones (opcional) +def require_permissions(*permissions: str, require_all: bool = True): + """ + Decorador para verificar permisos en funciones. + Útil para lógica de negocio fuera de rutas FastAPI. + + Ejemplo: + @require_permissions("invoice.edit", "invoice.view") + def update_invoice_logic(invoice_id: int, user_id: str, company_id: int, db: Session): + # Lógica de actualización + pass + """ + + def decorator(func: Callable): + @wraps(func) + def wrapper(*args, **kwargs): + # Extraer user_id, company_id y db de los argumentos + user_id = kwargs.get("user_id") + company_id = kwargs.get("company_id") + db = kwargs.get("db") + + if not all([user_id, company_id, db]): + raise ValueError( + "Function must receive 'user_id', 'company_id', and 'db' as keyword arguments" + ) + + # Verificar permisos + permission_service = PermissionService(db) + + if require_all: + has_access = permission_service.has_all_permissions( + user_id=user_id, + company_id=company_id, + permission_codes=list(permissions), + ) + else: + has_access = permission_service.has_any_permission( + user_id=user_id, + company_id=company_id, + permission_codes=list(permissions), + ) + + if not has_access: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Missing required permissions: {', '.join(permissions)}", + ) + + return func(*args, **kwargs) + + return wrapper + + return decorator + + +# Función helper para obtener permisos del usuario actual +async def get_current_user_permissions( + company_id: int, + current_user: dict = Depends(get_current_user), + permission_service: PermissionService = Depends(get_permission_service), +) -> set: + """ + Devuelve todos los permisos del usuario actual en la compañía. + Útil para endpoints que necesitan conocer los permisos disponibles. + """ + user_id = current_user.get("sub") or current_user.get("id") + + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User ID not found in token", + ) + + return permission_service.get_user_permissions(user_id, company_id, use_cache=True) diff --git a/backend/api/v1/modules/core/permissions/models.py b/backend/api/v1/modules/core/permissions/models.py new file mode 100644 index 0000000..03ca0dd --- /dev/null +++ b/backend/api/v1/modules/core/permissions/models.py @@ -0,0 +1,247 @@ +""" +Modelos de permisos multi-tenant para el sistema. +Este módulo define el sistema de permisos granular por compañia/tenant. +""" + +from datetime import datetime, timezone +from typing import Optional +from sqlalchemy import ( + String, + Integer, + ForeignKey, + DateTime, + Boolean, + UniqueConstraint, + Index, +) +from sqlalchemy.orm import relationship, Mapped, mapped_column +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin +from core.database import Base + + +# Modelo para permisos del sistema +# Representa acciones específicas como "invoice.view", "invoice.edit", etc. +class Permission(Base, TimestampMixin): + __tablename__ = "permissions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + code: Mapped[str] = mapped_column( + String(100), unique=True, nullable=False, index=True + ) + # Código único del permiso (ej: "invoice.view", "user.edit") + + description: Mapped[Optional[str]] = mapped_column(String(255)) + # Descripción legible del permiso + + module: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + # Módulo al que pertenece (ej: "invoice", "user", "report") + + action: Mapped[str] = mapped_column(String(50), nullable=False) + # Acción específica (ej: "view", "edit", "delete", "create") + + is_active: Mapped[bool] = mapped_column( + Boolean, default=True, server_default="true", nullable=False + ) + # Permite desactivar permisos sin eliminarlos + + __table_args__ = {"schema": "core", "extend_existing": True} + + # Relaciones + role_permissions: Mapped[list["RolePermission"]] = relationship( + "RolePermission", back_populates="permission", cascade="all, delete-orphan" + ) + user_company_permissions: Mapped[list["UserCompanyPermission"]] = relationship( + "UserCompanyPermission", + back_populates="permission", + cascade="all, delete-orphan", + ) + + +# Modelo para roles personalizados por compañia/tenant +# Cada compañia puede definir sus propios roles con nombres personalizados +class CompanyRole(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "company_roles" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + + name: Mapped[str] = mapped_column(String(100), nullable=False) + # Nombre del rol (ej: "Administrador", "Contador", "Vendedor") + + code: Mapped[str] = mapped_column(String(100), nullable=False) + # Código único del rol dentro del compañia (ej: "admin", "accountant") + + description: Mapped[Optional[str]] = mapped_column(String(255)) + # Descripción del rol + + is_active: Mapped[bool] = mapped_column( + Boolean, default=True, server_default="true", nullable=False + ) + # Permite desactivar roles sin eliminarlos + + # Restricción: el código del rol debe ser único por compañia + __table_args__ = ( + UniqueConstraint( + "company_id", "tenant_id", "code", name="uq_company_role_code" + ), + Index( + "ix_company_roles_company_id_is_active", + "company_id", + "tenant_id", + "is_active", + ), + {"schema": "core", "extend_existing": True}, + ) + + # Relaciones + role_permissions: Mapped[list["RolePermission"]] = relationship( + "RolePermission", back_populates="company_role", cascade="all, delete-orphan" + ) + user_company_roles: Mapped[list["UserCompanyRole"]] = relationship( + "UserCompanyRole", + back_populates="company_role", + cascade="all, delete-orphan", + ) + + +# Tabla de relación entre roles de compañia y permisos +# Define qué permisos tiene cada rol +class RolePermission(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "role_permissions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + + company_role_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("core.company_roles.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + permission_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("core.permissions.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + # Restricción: un permiso no puede estar duplicado en el mismo rol + __table_args__ = ( + UniqueConstraint("company_role_id", "permission_id", name="uq_role_permission"), + Index("ix_role_permissions_composite", "company_role_id", "permission_id"), + {"schema": "core", "extend_existing": True}, + ) + + # Relaciones + company_role: Mapped["CompanyRole"] = relationship( + "CompanyRole", back_populates="role_permissions" + ) + permission: Mapped["Permission"] = relationship( + "Permission", back_populates="role_permissions" + ) + + +# Tabla de relación entre usuarios y roles de compañia +# Define qué roles tiene cada usuario en cada compañia +class UserCompanyRole(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "user_company_roles" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + + user_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + # ID del usuario (puede ser UUID de Keycloak u otro identificador) + + company_role_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("core.company_roles.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + is_active: Mapped[bool] = mapped_column( + Boolean, default=True, server_default="true", nullable=False + ) + # Permite desactivar asignaciones sin eliminarlas + + assigned_by: Mapped[Optional[str]] = mapped_column(String(100)) + # ID del usuario que asignó este rol + + # Restricción: un usuario no puede tener el mismo rol duplicado en un compañia + __table_args__ = ( + UniqueConstraint( + "user_id", + "company_id", + "tenant_id", + "company_role_id", + name="uq_user_company_role", + ), + Index( + "ix_user_company_roles_user_company", + "user_id", + "company_id", + "tenant_id", + "is_active", + ), + {"schema": "core", "extend_existing": True}, + ) + + # Relaciones + company_role: Mapped["CompanyRole"] = relationship( + "CompanyRole", back_populates="user_company_roles" + ) + + +# Tabla para permisos directos de usuario por compañia (opcional) +# Permite asignar permisos específicos a un usuario sin necesidad de un rol +# Útil para casos excepcionales o permisos temporales +class UserCompanyPermission(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "user_company_permissions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + + user_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + # ID del usuario + + permission_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("core.permissions.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + is_granted: Mapped[bool] = mapped_column( + Boolean, default=True, server_default="true", nullable=False + ) + # True = permiso concedido, False = permiso revocado explícitamente + # Permite revocar permisos que vienen de roles + + is_active: Mapped[bool] = mapped_column( + Boolean, default=True, server_default="true", nullable=False + ) + + assigned_by: Mapped[Optional[str]] = mapped_column(String(100)) + expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime) + # Permite permisos temporales con fecha de expiración + + # Restricción: un usuario no puede tener el mismo permiso duplicado en un compañia + __table_args__ = ( + UniqueConstraint( + "user_id", + "company_id", + "tenant_id", + "permission_id", + name="uq_user_company_permission", + ), + Index( + "ix_user_company_permissions_composite", + "user_id", + "company_id", + "tenant_id", + "is_active", + ), + {"schema": "core", "extend_existing": True}, + ) + + # Relaciones + permission: Mapped["Permission"] = relationship( + "Permission", back_populates="user_company_permissions" + ) diff --git a/backend/api/v1/modules/core/permissions/registry.py b/backend/api/v1/modules/core/permissions/registry.py new file mode 100644 index 0000000..7647e5d --- /dev/null +++ b/backend/api/v1/modules/core/permissions/registry.py @@ -0,0 +1,101 @@ +""" +Registro centralizado para la modulación de permisos. +Permite que cada módulo registre sus propios permisos de forma dinámica. +""" + +import logging +from typing import Dict, List, Optional, Any +from dataclasses import dataclass, field + +logger = logging.getLogger(__name__) + +@dataclass +class PermissionDefinition: + """Representa la definición de un permiso en un módulo.""" + code: str + description: Optional[str] = None + module: Optional[str] = None + action: Optional[str] = None + is_active: bool = True + + def __post_init__(self): + """Lógica de autocompletado para evitar redundancia.""" + # Si el código tiene el formato "modulo.sub.accion" o "modulo.accion" + parts = self.code.split(".") + + # Extraer módulo si no se especificó + if not self.module and len(parts) > 1: + self.module = parts[0] + elif not self.module: + self.module = "system" # Default fallback + + # Extraer acción si no se especificó (es la última parte del código) + if not self.action and len(parts) > 1: + self.action = parts[-1] + elif not self.action: + self.action = "view" # Default fallback + + +class PermissionRegistry: + """ + Registro Singleton para permisos de la aplicación. + Cada módulo de la API debe importar este registro y dar de alta sus permisos. + """ + _instance = None + _permissions: Dict[str, PermissionDefinition] = {} + + def __new__(cls): + if cls._instance is None: + cls._instance = super(PermissionRegistry, cls).__new__(cls) + cls._permissions = {} + return cls._instance + + @classmethod + def register(cls, + code: str, + description: Optional[str] = None, + module: Optional[str] = None, + action: Optional[str] = None) -> None: + """ + Registra un nuevo permiso en el sistema. + """ + if code in cls._permissions: + logger.debug(f"Permiso {code} ya está registrado, actualizando metadatos.") + + cls._permissions[code] = PermissionDefinition( + code=code, + description=description, + module=module, + action=action + ) + + @classmethod + def register_many(cls, permissions_list: List[tuple]) -> None: + """ + Registra múltiples permisos desde una lista de tuplas. + Útil para migrar seeds estáticos. + """ + for item in permissions_list: + if len(item) == 2: # (code, description) + cls.register(code=item[0], description=item[1]) + elif len(item) >= 3: # (code, description, module, ...) + cls.register( + code=item[0], + description=item[1], + module=item[2], + action=item[3] if len(item) > 3 else None + ) + + @classmethod + def get_all(cls) -> List[PermissionDefinition]: + """Retorna todos los permisos registrados.""" + return list(cls._permissions.values()) + + @classmethod + def get_by_module(cls, module_name: str) -> List[PermissionDefinition]: + """Retorna los permisos de un módulo específico.""" + return [p for p in cls._permissions.values() if p.module == module_name] + + +# Instancia global para facilitar el acceso +registry = PermissionRegistry() diff --git a/backend/api/v1/modules/core/permissions/routes.py b/backend/api/v1/modules/core/permissions/routes.py new file mode 100644 index 0000000..6246dd6 --- /dev/null +++ b/backend/api/v1/modules/core/permissions/routes.py @@ -0,0 +1,1364 @@ +""" +Rutas API para el sistema de permisos multi-tenant. +Endpoints para gestión de permisos, roles y asignaciones. +""" + +from typing import List, Optional, Dict, Any +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.orm import Session + +from core.database import get_core_db +from core.security import ( + collect_user_role_names, + get_current_user, + validate_access_to_resource, + is_hub_admin, +) +from .dependencies import ( + PermissionChecker, + RequirePermission, + get_permission_service, + get_current_user_permissions, +) +from .cache import PermissionCache +from .service import PermissionService +from .models import Permission, CompanyRole +from .schemas import ( + PermissionResponse, + CompanyRoleResponse, + UserPermissionsResponse, + AssignRoleRequest, + GrantPermissionRequest, + SuccessResponse, + CreateRoleRequest, + UpdateRoleRequest, + AssignPermissionsToRoleRequest, + PermissionListResponse, + RoleListResponse, + UserRoleListResponse, + AssignUserRoleRequest, + UserCompanyRoleResponse, + UserPermissionResponse, + AssignUserPermissionRequest, + UserPermissionsListResponse, + EffectiveUserPermissionsResponse, +) + + +router = APIRouter(prefix="/permissions", tags=["permissions"]) + + +def _tenant_if_roles_or_user_admin( + db: Session, + company_id: int, + current_user: Dict[str, Any], +) -> int: + """ + Acceso a plantillas de rol y asignaciones en compañía: ``roles.view`` o gestión de + usuarios (``user.view`` / ``user.update`` / ``user.manage``). Evita exigir + ``roles.view`` a quien solo administra usuarios con permisos de app. + """ + return validate_access_to_resource( + db, + company_id, + current_user, + ["roles.view", "user.view", "user.update", "user.manage"], + require_all=False, + ) + + +# RUTAS DE CONSULTA DE PERMISOS + + +@router.get("/me", response_model=UserPermissionsResponse) +async def get_my_permissions( + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), + permission_service: PermissionService = Depends(get_permission_service), +): + """ + Obtiene los permisos y roles del usuario actual en la compañía actual. + Si la compañía no tiene roles definidos, realiza un bootstrap automático. + También realiza bootstrap si es un usuario con rol 'admin' en Keycloak pero sin roles locales. + """ + # 1. Validar acceso básico a la compañía + # Nota: pass None en required_permissions permite el paso al bootstrap + tenant_id = validate_access_to_resource(db, company_id, current_user) + + user_id = current_user.get("sub") or current_user.get("id") + + # 2. Determinar si es un admin de Keycloak para forzar bootstrap si es necesario + # Admin global: rol "admin" en Keycloak o hub_admin del Hub. + is_keycloak_admin = "admin" in collect_user_role_names(current_user) or is_hub_admin(current_user) + + # 3. Bootstrap: si la compañía no tiene roles, o si el usuario es admin, o si estamos en desarrollo y el usuario no tiene roles + from .models import CompanyRole, UserCompanyRole + from core.config import settings + + has_roles = db.query(CompanyRole).filter(CompanyRole.company_id == company_id).first() is not None + user_has_roles = db.query(UserCompanyRole).filter( + UserCompanyRole.user_id == user_id, + UserCompanyRole.company_id == company_id + ).first() is not None + + auto_bootstrap = (not has_roles) or \ + (is_keycloak_admin and not user_has_roles) or \ + (settings.ENVIRONMENT == "development" and (not user_has_roles or is_keycloak_admin)) + + if auto_bootstrap: + # Nota: bootstrap_super_admin ya hace commit e intenta no duplicar si el rol ya existe + permission_service.bootstrap_super_admin(user_id, company_id) + + # 4. Obtener permisos finales (caché habilitado en PermissionService) + permissions = permission_service.get_user_permissions( + user_id, company_id, use_cache=True + ) + + # 5. Obtener roles locales. Devolvemos el `code` (estable, kebab/snake) + # porque el frontend lo usa para checks (p.ej. bypass de "super_admin"). + # Los `name` legibles se exponen en otros endpoints de gestión de roles. + roles = permission_service.get_user_roles(user_id, company_id) + role_codes = [role.code for role in roles] + + # 6. Derivar sistemas permitidos desde los permisos de sistema + allowed_systems = [ + sys for sys in ("inventory", "fixed_asset") + if f"system.{sys}.access" in permissions + ] + + return UserPermissionsResponse( + user_id=user_id, + company_id=company_id, + tenant_id=tenant_id, + permissions=list(permissions), + roles=role_codes, + allowed_systems=allowed_systems, + ) + + +@router.get("/available", response_model=PermissionListResponse) +async def list_available_permissions( + db: Session = Depends(get_core_db), + page: int = Query(1, ge=1, description="Número de página"), + page_size: int = Query(100, ge=1, le=1000, description="Tamaño de página"), + module: Optional[str] = Query(None, description="Filtrar por módulo"), + _: None = Depends(RequirePermission("roles.view")), +): + """ + Lista todos los permisos disponibles en el sistema. + Requiere permiso: permissions.view + """ + query = db.query(Permission).filter(Permission.is_active == True) + + if module: + query = query.filter(Permission.module == module) + + total = query.count() + permissions = query.offset((page - 1) * page_size).limit(page_size).all() + + return PermissionListResponse( + items=permissions, total=total, page=page, page_size=page_size + ) + + +@router.get("/roles", response_model=RoleListResponse) +async def list_company_roles( + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), + page: int = Query(1, ge=1, description="Número de página"), + page_size: int = Query(100, ge=1, le=1000, description="Tamaño de página"), +): + """ + Lista todos los roles del companye actual. + TODO: Agregar verificación de permisos + """ + tenant_id = _tenant_if_roles_or_user_admin(db, company_id, current_user) + + query = db.query(CompanyRole).filter( + CompanyRole.company_id == company_id + ) + + total = query.count() + roles = query.offset((page - 1) * page_size).limit(page_size).all() + + return RoleListResponse(items=roles, total=total, page=page, page_size=page_size) + + +@router.get("/users/{user_id}", response_model=UserPermissionsResponse) +async def get_user_permissions( + user_id: str, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), + permission_service: PermissionService = Depends(get_permission_service), +): + """ + Obtiene los permisos y roles de un usuario específico. + Requiere roles.view o permisos de gestión de usuarios en la compañía. + """ + tenant_id = _tenant_if_roles_or_user_admin(db, company_id, current_user) + + permissions = permission_service.get_user_permissions( + user_id, company_id, use_cache=True + ) + roles = permission_service.get_user_roles(user_id, company_id) + role_codes = [role.code for role in roles] + + return UserPermissionsResponse( + user_id=user_id, + company_id=company_id, + tenant_id=tenant_id, + permissions=list(permissions), + roles=role_codes, + ) + + +# RUTAS CRUD DE PERMISOS + + +@router.get("", response_model=PermissionListResponse) +async def list_permissions( + db: Session = Depends(get_core_db), + page: int = Query(1, ge=1, description="Número de página"), + page_size: int = Query(100, ge=1, le=1000, description="Tamaño de página"), + module: Optional[str] = Query(None, description="Filtrar por módulo"), + action: Optional[str] = Query(None, description="Filtrar por acción"), + search: Optional[str] = Query(None, description="Buscar por código o descripción"), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Lista todos los permisos disponibles en el sistema. + TODO: Agregar verificación de permisos + """ + query = db.query(Permission).filter(Permission.is_active == True) + + if module: + query = query.filter(Permission.module == module) + if action: + query = query.filter(Permission.action == action) + if search: + search_filter = f"%{search}%" + query = query.filter( + (Permission.code.ilike(search_filter)) + | (Permission.description.ilike(search_filter)) + ) + + total = query.count() + permissions = query.offset((page - 1) * page_size).limit(page_size).all() + + return PermissionListResponse( + items=permissions, total=total, page=page, page_size=page_size + ) + + +@router.get("/modules", response_model=List[str]) +async def get_modules( + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Obtiene la lista de módulos únicos. + TODO: Agregar verificación de permisos + """ + modules = ( + db.query(Permission.module) + .filter(Permission.is_active == True) + .distinct() + .all() + ) + return [m[0] for m in modules] + + +@router.get("/actions", response_model=List[str]) +async def get_actions( + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Obtiene la lista de acciones únicas. + TODO: Agregar verificación de permisos + """ + actions = ( + db.query(Permission.action) + .filter(Permission.is_active == True) + .distinct() + .all() + ) + return [a[0] for a in actions] + + +# RUTAS DE GESTIÓN DE ASIGNACIONES DE ROLES A USUARIOS + + +@router.get("/user-roles", response_model=UserRoleListResponse) +async def list_user_roles( + company_id: int = Query(..., description="Company ID"), + user_id: Optional[str] = Query(None, description="Filtrar por user_id"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), + page: int = Query(1, ge=1, description="Número de página"), + page_size: int = Query(100, ge=1, le=1000, description="Tamaño de página"), +): + """ + Lista todas las asignaciones de roles a usuarios en la compañía. + """ + from .models import UserCompanyRole + from sqlalchemy.orm import joinedload + + tenant_id = _tenant_if_roles_or_user_admin(db, company_id, current_user) + + query = db.query(UserCompanyRole).options( + joinedload(UserCompanyRole.company_role) + ).filter( + UserCompanyRole.company_id == company_id, + UserCompanyRole.tenant_id == tenant_id + ) + + if user_id: + query = query.filter(UserCompanyRole.user_id == user_id) + + total = query.count() + user_roles = query.offset((page - 1) * page_size).limit(page_size).all() + + return UserRoleListResponse( + items=user_roles, + total=total, + page=page, + page_size=page_size + ) + + +@router.post("/user-roles", response_model=UserCompanyRoleResponse, status_code=status.HTTP_201_CREATED) +async def assign_user_role( + request: AssignUserRoleRequest, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Asigna un rol a un usuario. + """ + from .models import UserCompanyRole, CompanyRole + from sqlalchemy.exc import IntegrityError + from sqlalchemy.orm import joinedload + + tenant_id = _tenant_if_roles_or_user_admin(db, company_id, current_user) + assigner_id = current_user.get("sub") or current_user.get("id") + + # Verificar que el rol existe + role = db.query(CompanyRole).filter( + CompanyRole.id == request.company_role_id, + CompanyRole.company_id == company_id + ).first() + + if not role: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Role not found" + ) + + # Crear la asignación + user_role = UserCompanyRole( + user_id=request.user_id, + company_id=company_id, + tenant_id=tenant_id, + company_role_id=request.company_role_id, + assigned_by=assigner_id, + is_active=True + ) + + try: + db.add(user_role) + db.commit() + db.refresh(user_role) + + # Invalidar caché de permisos del usuario afectado + try: + PermissionCache().invalidate_user(tenant_id, company_id, request.user_id) + except Exception: + pass + + # Recargar con la relación company_role + user_role = db.query(UserCompanyRole).options( + joinedload(UserCompanyRole.company_role) + ).filter(UserCompanyRole.id == user_role.id).first() + + return user_role + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="User already has this role assigned" + ) + + +@router.delete("/user-roles/{user_role_id}") +async def remove_user_role( + user_role_id: int, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Remueve una asignación de rol a usuario. + """ + from .models import UserCompanyRole + + tenant_id = _tenant_if_roles_or_user_admin(db, company_id, current_user) + + user_role = db.query(UserCompanyRole).filter( + UserCompanyRole.id == user_role_id, + UserCompanyRole.company_id == company_id, + UserCompanyRole.tenant_id == tenant_id + ).first() + + if not user_role: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User role assignment not found" + ) + + db.delete(user_role) + db.commit() + + # Invalidar caché de permisos del usuario afectado + try: + PermissionCache().invalidate_user(tenant_id, company_id, user_role.user_id) + except Exception: + pass + + return {"success": True, "message": "User role assignment removed"} + + +@router.get("/{permission_id}", response_model=PermissionResponse) +async def get_permission( + permission_id: int, + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Obtiene un permiso por ID. + TODO: Agregar verificación de permisos + """ + permission = db.query(Permission).filter(Permission.id == permission_id).first() + if not permission: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Permission not found" + ) + return permission + + + +@router.post("/sync", response_model=Dict[str, Any]) +async def sync_permissions_endpoint( + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), + permission_service: PermissionService = Depends(get_permission_service), + _: None = Depends(RequirePermission("roles.edit")), +): + """ + Sincroniza dinámicamente los permisos registrados en los módulos con la base de datos. + Requiere permiso: permissions.edit + """ + result = permission_service.sync_permissions() + return { + "success": True, + "message": "Permissions synchronized successfully", + "data": result + } + + +from .schemas import CreatePermissionRequest + + +@router.post( + "", response_model=PermissionResponse, status_code=status.HTTP_201_CREATED +) +async def create_permission( + request: CreatePermissionRequest, + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Crea un nuevo permiso en el sistema. + TODO: Agregar verificación de permisos + """ + # Verificar que el código no esté en uso + existing = db.query(Permission).filter(Permission.code == request.code).first() + if existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Permission with code '{request.code}' already exists", + ) + + permission = Permission( + code=request.code, + description=request.description, + module=request.module, + action=request.action, + ) + db.add(permission) + db.commit() + db.refresh(permission) + return permission + + +@router.put("/{permission_id}", response_model=PermissionResponse) +async def update_permission( + permission_id: int, + request: CreatePermissionRequest, + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Actualiza un permiso existente. + TODO: Agregar verificación de permisos + """ + permission = db.query(Permission).filter(Permission.id == permission_id).first() + if not permission: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Permission not found" + ) + + # Si se cambia el código, verificar que no exista + if request.code != permission.code: + existing = db.query(Permission).filter(Permission.code == request.code).first() + if existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Permission with code '{request.code}' already exists", + ) + + permission.code = request.code + permission.description = request.description + permission.module = request.module + permission.action = request.action + db.commit() + db.refresh(permission) + return permission + + +@router.delete("/{permission_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_permission( + permission_id: int, + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Elimina (desactiva) un permiso. + TODO: Agregar verificación de permisos + """ + permission = db.query(Permission).filter(Permission.id == permission_id).first() + if not permission: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Permission not found" + ) + + permission.is_active = False + db.commit() + + +# RUTAS DE GESTIÓN DE ROLES + + +@router.post( + "/roles", response_model=CompanyRoleResponse, status_code=status.HTTP_201_CREATED +) +async def create_role( + request: CreateRoleRequest, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Crea un nuevo rol personalizado para el companye. + TODO: Agregar verificación de permisos + """ + tenant_id = validate_access_to_resource(db, company_id, current_user, ["roles.create"]) + + # Verificar que el código no esté en uso + existing = ( + db.query(CompanyRole) + .filter(CompanyRole.company_id == company_id, CompanyRole.code == request.code) + .first() + ) + + if existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Role with code '{request.code}' already exists for this company", + ) + + # Crear el rol + role = CompanyRole( + company_id=company_id, + tenant_id=tenant_id, + name=request.name, + code=request.code, + description=request.description, + ) + + db.add(role) + db.commit() + db.refresh(role) + + # Asignar permisos si se especificaron + if request.permission_ids: + from .models import RolePermission + + for perm_id in request.permission_ids: + role_perm = RolePermission(company_role_id=role.id, permission_id=perm_id) + db.add(role_perm) + db.commit() + + return role + + +@router.patch("/roles/{role_id}", response_model=CompanyRoleResponse) +async def update_role( + role_id: int, + request: UpdateRoleRequest, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Actualiza un rol existente. + TODO: Agregar verificación de permisos + """ + tenant_id = validate_access_to_resource(db, company_id, current_user, ["roles.edit"]) + + role = ( + db.query(CompanyRole) + .filter(CompanyRole.id == role_id, CompanyRole.company_id == company_id) + .first() + ) + + if not role: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Role not found" + ) + + # Actualizar campos + if request.name is not None: + role.name = request.name + if request.description is not None: + role.description = request.description + if request.is_active is not None: + role.is_active = request.is_active + + db.commit() + db.refresh(role) + + return role + + +@router.delete("/roles/{role_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_role( + role_id: int, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Elimina permanentemente un rol y sus permisos asociados. + TODO: Agregar verificación de permisos + """ + from .models import RolePermission, UserCompanyRole + + tenant_id = validate_access_to_resource(db, company_id, current_user, ["roles.delete"]) + + role = ( + db.query(CompanyRole) + .filter(CompanyRole.id == role_id, CompanyRole.company_id == company_id) + .first() + ) + + if not role: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Role not found" + ) + + # Verificar si hay usuarios con este rol + users_with_role = db.query(UserCompanyRole).filter( + UserCompanyRole.company_role_id == role_id + ).count() + + if users_with_role > 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"No se puede eliminar el rol porque tiene {users_with_role} usuario(s) asignado(s). Primero remueve el rol de los usuarios." + ) + + # Eliminar permisos asociados al rol + db.query(RolePermission).filter(RolePermission.company_role_id == role_id).delete() + + # Eliminar el rol + db.delete(role) + db.commit() + + # Cambiar la composición de permisos de rol invalida el caché de la compañía + try: + PermissionCache().invalidate_company(company_id) + except Exception: + pass + + +# RUTAS DE GESTIÓN DE PERMISOS POR ROL + + +@router.get("/roles/{role_id}/permissions") +async def get_role_permissions( + role_id: int, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Obtiene todos los permisos asignados a un rol. + """ + from .models import RolePermission + + tenant_id = _tenant_if_roles_or_user_admin(db, company_id, current_user) + + # Verificar que el rol existe y pertenece al company + role = ( + db.query(CompanyRole) + .filter(CompanyRole.id == role_id, CompanyRole.company_id == company_id) + .first() + ) + + if not role: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Role not found" + ) + + # Obtener permisos del rol con información completa + role_permissions = ( + db.query(RolePermission) + .join(Permission, RolePermission.permission_id == Permission.id) + .filter(RolePermission.company_role_id == role_id) + .all() + ) + + permissions_list = [] + for rp in role_permissions: + permission = db.query(Permission).filter(Permission.id == rp.permission_id).first() + permissions_list.append({ + "id": rp.id, + "company_role_id": rp.company_role_id, + "permission_id": rp.permission_id, + "permission": { + "id": permission.id, + "code": permission.code, + "module": permission.module, + "action": permission.action, + "description": permission.description + } if permission else None + }) + + return { + "role_id": role_id, + "permissions": permissions_list, + "total": len(permissions_list) + } + + +@router.post("/roles/{role_id}/permissions") +async def assign_permission_to_role( + role_id: int, + permission_id: int = Query(..., description="Permission ID"), + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Asigna un permiso a un rol. + """ + from .models import RolePermission + + tenant_id = _tenant_if_roles_or_user_admin(db, company_id, current_user) + + # Verificar que el rol existe + role = ( + db.query(CompanyRole) + .filter(CompanyRole.id == role_id, CompanyRole.company_id == company_id) + .first() + ) + + if not role: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Role not found" + ) + + # Verificar que el permiso existe + permission = db.query(Permission).filter(Permission.id == permission_id).first() + + if not permission: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Permission not found" + ) + + # Verificar si ya existe + existing = ( + db.query(RolePermission) + .filter( + RolePermission.company_role_id == role_id, + RolePermission.permission_id == permission_id + ) + .first() + ) + + if existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Permission already assigned to this role" + ) + + # Crear la asignación + role_permission = RolePermission( + company_role_id=role_id, + permission_id=permission_id, + tenant_id=tenant_id, + company_id=company_id + ) + + db.add(role_permission) + db.commit() + db.refresh(role_permission) + + # Cualquier cambio en permisos por rol invalida el caché de la compañía + try: + PermissionCache().invalidate_company(company_id) + except Exception: + pass + + return {"success": True, "message": "Permission assigned to role"} + + +@router.post("/roles/{role_id}/permissions/batch") +async def assign_multiple_permissions_to_role( + role_id: int, + request: AssignPermissionsToRoleRequest, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Asigna múltiples permisos a un rol. + """ + from .models import RolePermission + + tenant_id = _tenant_if_roles_or_user_admin(db, company_id, current_user) + + # Verificar que el rol existe + role = ( + db.query(CompanyRole) + .filter(CompanyRole.id == role_id, CompanyRole.company_id == company_id) + .first() + ) + + if not role: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Role not found" + ) + + added_count = 0 + + for permission_id in request.permission_ids: + # Verificar que el permiso existe + permission = db.query(Permission).filter(Permission.id == permission_id).first() + + if not permission: + continue # Saltear permisos que no existen + + # Verificar si ya existe + existing = ( + db.query(RolePermission) + .filter( + RolePermission.company_role_id == role_id, + RolePermission.permission_id == permission_id + ) + .first() + ) + + if existing: + continue # Saltear si ya existe + + # Crear la asignación + role_permission = RolePermission( + company_role_id=role_id, + permission_id=permission_id, + tenant_id=tenant_id, + company_id=company_id + ) + + db.add(role_permission) + added_count += 1 + + db.commit() + + # Invalida caché para todos los usuarios de la compañía + try: + PermissionCache().invalidate_company(company_id) + except Exception: + pass + + return { + "success": True, + "message": f"{added_count} permissions assigned to role", + "added_count": added_count + } + + +@router.delete("/roles/{role_id}/permissions/{permission_id}") +async def remove_permission_from_role( + role_id: int, + permission_id: int, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Remueve un permiso de un rol. + """ + from .models import RolePermission + + tenant_id = _tenant_if_roles_or_user_admin(db, company_id, current_user) + + # Buscar la asignación + role_permission = ( + db.query(RolePermission) + .filter( + RolePermission.company_role_id == role_id, + RolePermission.permission_id == permission_id + ) + .first() + ) + + if not role_permission: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Permission assignment not found" + ) + + db.delete(role_permission) + db.commit() + + try: + PermissionCache().invalidate_company(company_id) + except Exception: + pass + + return {"success": True, "message": "Permission removed from role"} + + +# RUTAS DE ASIGNACIÓN DE ROLES Y PERMISOS + + +@router.post( + "/assign-role", response_model=SuccessResponse, status_code=status.HTTP_201_CREATED +) +async def assign_role( + request: AssignRoleRequest, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), + permission_service: PermissionService = Depends(get_permission_service), +): + """ + Asigna un rol a un usuario en el companye actual. + TODO: Agregar verificación de permisos + """ + tenant_id = _tenant_if_roles_or_user_admin(db, company_id, current_user) + + assigner_id = current_user.get("sub") or current_user.get("id") + + try: + user_role = permission_service.assign_role_to_user( + user_id=request.user_id, + company_id=company_id, + role_id=request.role_id, + assigned_by=assigner_id, + ) + + return SuccessResponse( + message=f"Role assigned to user {request.user_id}", + data={"assignment_id": user_role.id}, + ) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + + +@router.post( + "/grant-permission", + response_model=SuccessResponse, + status_code=status.HTTP_201_CREATED, +) +async def grant_permission( + request: GrantPermissionRequest, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), + permission_service: PermissionService = Depends(get_permission_service), +): + """ + Concede un permiso directo a un usuario en el companye actual. + Requiere ``permissions.grant`` o gestión de usuarios (``user.manage`` / ``user.update``). + """ + tenant_id = validate_access_to_resource( + db, + company_id, + current_user, + ["permissions.grant", "user.manage", "user.update"], + require_all=False, + ) + + assigner_id = current_user.get("sub") or current_user.get("id") + + try: + user_permission = permission_service.grant_direct_permission( + user_id=request.user_id, + company_id=company_id, + permission_code=request.permission_code, + assigned_by=assigner_id, + expires_at=request.expires_at, + ) + + return SuccessResponse( + message=f"Permission {request.permission_code} granted to user {request.user_id}", + data={ + "permission_id": user_permission.id, + "expires_at": ( + user_permission.expires_at.isoformat() + if user_permission.expires_at + else None + ), + }, + ) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + + +# EJEMPLOS DE RUTAS PROTEGIDAS CON PERMISOS + + +@router.get("/examples/invoices") +async def example_view_invoices(_: None = Depends(RequirePermission("invoice.view"))): + """ + Endpoint de ejemplo que requiere el permiso invoice.view + """ + return { + "message": "You have permission to view invoices", + "invoices": [ + {"id": 1, "number": "INV-001", "amount": 1000}, + {"id": 2, "number": "INV-002", "amount": 2000}, + ], + } + + +@router.post("/examples/invoices") +async def example_edit_invoice( + _: None = Depends( + PermissionChecker(["invoice.view", "invoice.edit"], require_all=True) + ) +): + """ + Endpoint de ejemplo que requiere AMBOS permisos: invoice.view e invoice.edit + """ + return { + "message": "Invoice updated successfully", + "invoice": {"id": 1, "number": "INV-001", "amount": 1500}, + } + + +@router.get("/examples/reports") +async def example_view_reports( + _: None = Depends( + PermissionChecker( + ["report.financial.view", "report.admin.view"], require_all=False + ) + ) +): + """ + Endpoint de ejemplo que requiere AL MENOS UNO de los permisos especificados. + """ + return { + "message": "Financial reports", + "reports": ["Monthly P&L", "Cash Flow", "Balance Sheet"], + } + + +@router.get("/examples/dashboard") +async def example_dashboard( + user_permissions: set = Depends(get_current_user_permissions), +): + """ + Dashboard dinámico que muestra diferentes widgets según los permisos del usuario. + """ + widgets = [] + + if "invoice.view" in user_permissions: + widgets.append( + { + "type": "invoices", + "title": "Recent Invoices", + "data": [{"id": 1, "number": "INV-001"}], + } + ) + + if "report.financial.view" in user_permissions: + widgets.append( + { + "type": "financial", + "title": "Financial Summary", + "data": {"revenue": 50000, "expenses": 30000}, + } + ) + + if "user.view" in user_permissions: + widgets.append( + {"type": "users", "title": "User Activity", "data": {"active_users": 42}} + ) + + return {"permissions": list(user_permissions), "widgets": widgets} + + +# RUTAS DE GESTIÓN DE PERMISOS INDIVIDUALES DE USUARIO + + +@router.get("/users/{user_id}/permissions") +async def get_user_individual_permissions( + user_id: str, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +) -> UserPermissionsListResponse: + """ + Obtiene los permisos individuales asignados a un usuario específico + (no incluye los permisos heredados de roles). + """ + from .models import UserCompanyPermission + from sqlalchemy.orm import joinedload + + tenant_id = _tenant_if_roles_or_user_admin(db, company_id, current_user) + + user_permissions = ( + db.query(UserCompanyPermission) + .options(joinedload(UserCompanyPermission.permission)) + .filter( + UserCompanyPermission.user_id == user_id, + UserCompanyPermission.company_id == company_id, + UserCompanyPermission.tenant_id == tenant_id, + UserCompanyPermission.is_active == True, + ) + .all() + ) + + return UserPermissionsListResponse( + items=[UserPermissionResponse.model_validate(up) for up in user_permissions], + total=len(user_permissions), + ) + + +@router.get("/users/{user_id}/permissions/effective") +async def get_user_effective_permissions( + user_id: str, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +) -> EffectiveUserPermissionsResponse: + """ + Obtiene los permisos efectivos de un usuario: + - Permisos de roles + - Permisos individuales concedidos + - Permisos revocados + - Permisos finales (roles + concedidos - revocados) + """ + from .models import UserCompanyPermission, UserCompanyRole, RolePermission + from sqlalchemy.orm import joinedload + + tenant_id = _tenant_if_roles_or_user_admin(db, company_id, current_user) + + # 1. Obtener permisos de roles + role_permissions_query = ( + db.query(Permission) + .join(RolePermission, RolePermission.permission_id == Permission.id) + .join(CompanyRole, CompanyRole.id == RolePermission.company_role_id) + .join(UserCompanyRole, UserCompanyRole.company_role_id == CompanyRole.id) + .filter( + UserCompanyRole.user_id == user_id, + UserCompanyRole.company_id == company_id, + UserCompanyRole.tenant_id == tenant_id, + UserCompanyRole.is_active == True, + CompanyRole.is_active == True, + Permission.is_active == True, + ) + .distinct() + .all() + ) + + # 2. Obtener permisos individuales + individual_permissions = ( + db.query(UserCompanyPermission) + .options(joinedload(UserCompanyPermission.permission)) + .filter( + UserCompanyPermission.user_id == user_id, + UserCompanyPermission.company_id == company_id, + UserCompanyPermission.tenant_id == tenant_id, + UserCompanyPermission.is_active == True, + ) + .all() + ) + + granted_permissions = [ + up.permission for up in individual_permissions + if up.is_granted and up.permission and up.permission.is_active + ] + + revoked_permissions = [ + up.permission for up in individual_permissions + if not up.is_granted and up.permission and up.permission.is_active + ] + + # 3. Calcular permisos efectivos + revoked_ids = {p.id for p in revoked_permissions} + role_perms_filtered = [p for p in role_permissions_query if p.id not in revoked_ids] + + # Combinar y eliminar duplicados + all_perms = {} + for p in role_perms_filtered: + all_perms[p.id] = p + for p in granted_permissions: + all_perms[p.id] = p + + effective_permissions = list(all_perms.values()) + + return EffectiveUserPermissionsResponse( + user_id=user_id, + company_id=company_id, + role_permissions=[PermissionResponse.model_validate(p) for p in role_permissions_query], + granted_permissions=[PermissionResponse.model_validate(p) for p in granted_permissions], + revoked_permissions=[PermissionResponse.model_validate(p) for p in revoked_permissions], + effective_permissions=[PermissionResponse.model_validate(p) for p in effective_permissions], + ) + + +@router.post("/users/{user_id}/permissions", status_code=status.HTTP_201_CREATED) +async def assign_user_permission( + user_id: str, + request: AssignUserPermissionRequest, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +) -> UserPermissionResponse: + """ + Asigna un permiso individual a un usuario. + - is_granted=True: Concede el permiso (útil para permisos extra) + - is_granted=False: Revoca el permiso (útil para quitar permisos del rol) + """ + from .models import UserCompanyPermission + + tenant_id = _tenant_if_roles_or_user_admin(db, company_id, current_user) + + # Verificar que el permiso existe + permission = db.query(Permission).filter(Permission.id == request.permission_id).first() + if not permission: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Permission not found" + ) + + # Verificar si ya existe + existing = ( + db.query(UserCompanyPermission) + .filter( + UserCompanyPermission.user_id == user_id, + UserCompanyPermission.company_id == company_id, + UserCompanyPermission.tenant_id == tenant_id, + UserCompanyPermission.permission_id == request.permission_id, + ) + .first() + ) + + if existing: + # Actualizar existente + existing.is_granted = request.is_granted + existing.is_active = True + existing.assigned_by = current_user.get("sub") + existing.expires_at = request.expires_at + db.commit() + db.refresh(existing) + # Invalidar caché del usuario afectado + try: + PermissionCache().invalidate_user(tenant_id, company_id, user_id) + except Exception: + pass + return UserPermissionResponse.model_validate(existing) + + # Crear nuevo + user_permission = UserCompanyPermission( + user_id=user_id, + company_id=company_id, + tenant_id=tenant_id, + permission_id=request.permission_id, + is_granted=request.is_granted, + assigned_by=current_user.get("sub"), + expires_at=request.expires_at, + ) + + db.add(user_permission) + db.commit() + db.refresh(user_permission) + + # Invalidar caché del usuario afectado + try: + PermissionCache().invalidate_user(tenant_id, company_id, user_id) + except Exception: + pass + + return UserPermissionResponse.model_validate(user_permission) + + +@router.delete("/users/{user_id}/permissions/{permission_id}", status_code=status.HTTP_204_NO_CONTENT) +async def remove_user_permission( + user_id: str, + permission_id: int, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Elimina un permiso individual de un usuario. + """ + from .models import UserCompanyPermission + + tenant_id = _tenant_if_roles_or_user_admin(db, company_id, current_user) + + user_permission = ( + db.query(UserCompanyPermission) + .filter( + UserCompanyPermission.user_id == user_id, + UserCompanyPermission.company_id == company_id, + UserCompanyPermission.tenant_id == tenant_id, + UserCompanyPermission.permission_id == permission_id, + ) + .first() + ) + + if not user_permission: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User permission not found" + ) + + db.delete(user_permission) + db.commit() + + # Invalidar caché del usuario afectado + try: + PermissionCache().invalidate_user(tenant_id, company_id, user_id) + except Exception: + pass diff --git a/backend/api/v1/modules/core/permissions/schemas.py b/backend/api/v1/modules/core/permissions/schemas.py new file mode 100644 index 0000000..054af99 --- /dev/null +++ b/backend/api/v1/modules/core/permissions/schemas.py @@ -0,0 +1,331 @@ +""" +Esquemas Pydantic para el módulo de permisos. +Define los modelos de request/response para las APIs de permisos. +""" + +from typing import List, Optional +from datetime import datetime +from pydantic import BaseModel, Field, ConfigDict + + +# ============================================================================ +# SCHEMAS DE RESPONSE +# ============================================================================ + + +class PermissionResponse(BaseModel): + """Esquema de respuesta para un permiso individual.""" + + id: int + code: str = Field(..., description="Código único del permiso (ej: 'invoice.view')") + description: Optional[str] = Field(None, description="Descripción del permiso") + module: str = Field(..., description="Módulo al que pertenece (ej: 'invoice')") + action: str = Field(..., description="Acción específica (ej: 'view', 'edit')") + is_active: bool = Field(..., description="Si el permiso está activo") + + model_config = ConfigDict(from_attributes=True) + + +class CompanyRoleResponse(BaseModel): + """Esquema de respuesta para un rol de companye.""" + + id: int + company_id: int = Field(..., description="ID del companye al que pertenece el rol") + name: str = Field(..., description="Nombre del rol (ej: 'Administrador')") + code: str = Field(..., description="Código del rol (ej: 'admin')") + description: Optional[str] = Field(None, description="Descripción del rol") + is_active: bool = Field(..., description="Si el rol está activo") + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class CompanyRoleWithPermissionsResponse(CompanyRoleResponse): + """Esquema de respuesta para un rol con sus permisos incluidos.""" + + permissions: List[PermissionResponse] = Field( + default_factory=list, description="Lista de permisos asignados a este rol" + ) + + +class UserPermissionsResponse(BaseModel): + """Esquema de respuesta para los permisos de un usuario.""" + + user_id: str = Field(..., description="ID del usuario") + company_id: int = Field(..., description="ID del companye") + tenant_id: Optional[int] = Field( + default=None, + description=( + "ID del tenant resuelto en backend para esta compañía. Permite " + "al frontend dejar de leer tenant_id desde claims del JWT." + ), + ) + permissions: List[str] = Field( + default_factory=list, description="Lista de códigos de permisos del usuario" + ) + roles: List[str] = Field( + default_factory=list, description="Lista de nombres de roles del usuario" + ) + allowed_systems: List[str] = Field( + default_factory=list, + description="Sistemas a los que el usuario tiene acceso: fixed_asset, inventory", + ) + + +class UserCompanyRoleResponse(BaseModel): + """Esquema de respuesta para la asignación de rol a usuario.""" + + id: int + user_id: str + company_id: int + company_role_id: int + is_active: bool + created_at: datetime + assigned_by: Optional[str] = None + company_role: Optional[CompanyRoleResponse] = Field(None, description="Información del rol asignado") + + model_config = ConfigDict(from_attributes=True) + + +class UserCompanyPermissionResponse(BaseModel): + """Esquema de respuesta para un permiso directo de usuario.""" + + id: int + user_id: str + company_id: int + permission_id: int + permission_code: Optional[str] = None + is_granted: bool = Field( + ..., description="True si está concedido, False si está revocado" + ) + is_active: bool + created_at: datetime + assigned_by: Optional[str] = None + expires_at: Optional[datetime] = Field( + None, description="Fecha de expiración del permiso" + ) + + model_config = ConfigDict(from_attributes=True) + + +# ============================================================================ +# SCHEMAS DE REQUEST +# ============================================================================ + + +class AssignRoleRequest(BaseModel): + """Esquema de request para asignar un rol a un usuario.""" + + user_id: str = Field(..., description="ID del usuario al que se asignará el rol") + role_id: int = Field(..., description="ID del rol a asignar") + + +class RemoveRoleRequest(BaseModel): + """Esquema de request para remover un rol de un usuario.""" + + user_id: str = Field(..., description="ID del usuario") + role_id: int = Field(..., description="ID del rol a remover") + + +class GrantPermissionRequest(BaseModel): + """Esquema de request para conceder un permiso directo a un usuario.""" + + user_id: str = Field(..., description="ID del usuario") + permission_code: str = Field( + ..., description="Código del permiso a conceder (ej: 'invoice.delete')" + ) + expires_at: Optional[datetime] = Field( + None, description="Fecha de expiración del permiso (opcional)" + ) + + +class RevokePermissionRequest(BaseModel): + """Esquema de request para revocar un permiso directo.""" + + user_id: str = Field(..., description="ID del usuario") + permission_code: str = Field(..., description="Código del permiso a revocar") + + +class CreateRoleRequest(BaseModel): + """Esquema de request para crear un rol personalizado.""" + + name: str = Field(..., min_length=1, max_length=100, description="Nombre del rol") + code: str = Field( + ..., + min_length=1, + max_length=100, + description="Código único del rol (ej: 'custom_admin')", + ) + description: Optional[str] = Field( + None, max_length=255, description="Descripción del rol" + ) + permission_ids: List[int] = Field( + default_factory=list, description="IDs de permisos a asignar al rol" + ) + + +class UpdateRoleRequest(BaseModel): + """Esquema de request para actualizar un rol existente.""" + + name: Optional[str] = Field(None, min_length=1, max_length=100) + description: Optional[str] = Field(None, max_length=255) + is_active: Optional[bool] = None + + +class AssignPermissionsToRoleRequest(BaseModel): + """Esquema de request para asignar permisos a un rol.""" + + permission_ids: List[int] = Field( + ..., description="Lista de IDs de permisos a asignar al rol" + ) + replace_existing: bool = Field( + False, + description="Si True, reemplaza los permisos existentes. Si False, los agrega.", + ) + + +class CreatePermissionRequest(BaseModel): + """Esquema de request para crear un nuevo permiso (uso administrativo).""" + + code: str = Field( + ..., + min_length=1, + max_length=100, + description="Código único del permiso (ej: 'custom_module.action')", + ) + description: Optional[str] = Field(None, max_length=255) + module: str = Field( + ..., min_length=1, max_length=50, description="Módulo del permiso" + ) + action: str = Field( + ..., min_length=1, max_length=50, description="Acción del permiso" + ) + + +# ============================================================================ +# SCHEMAS DE RESPUESTA GENÉRICOS +# ============================================================================ + + +class SuccessResponse(BaseModel): + """Respuesta genérica de éxito.""" + + success: bool = True + message: str = Field(..., description="Mensaje descriptivo de la operación") + data: Optional[dict] = Field(None, description="Datos adicionales opcionales") + + +class ErrorResponse(BaseModel): + """Respuesta genérica de error.""" + + success: bool = False + detail: str = Field(..., description="Descripción del error") + error_code: Optional[str] = Field(None, description="Código de error específico") + + +# ============================================================================ +# SCHEMAS DE PAGINACIÓN +# ============================================================================ + + +class PaginatedResponse(BaseModel): + """Esquema genérico para respuestas paginadas.""" + + items: List[dict] = Field(default_factory=list) + total: int = Field(..., description="Total de items disponibles") + page: int = Field(..., description="Página actual") + page_size: int = Field(..., description="Tamaño de página") + total_pages: int = Field(..., description="Total de páginas disponibles") + + +class PermissionListResponse(BaseModel): + """Lista paginada de permisos.""" + + items: List[PermissionResponse] + total: int + page: int = 1 + page_size: int = 100 + + +class RoleListResponse(BaseModel): + """Lista paginada de roles.""" + + items: List[CompanyRoleResponse] + total: int + page: int = 1 + page_size: int = 100 + + +class UserRoleListResponse(BaseModel): + """Lista paginada de asignaciones de roles a usuarios.""" + + items: List[UserCompanyRoleResponse] + total: int + page: int = 1 + page_size: int = 100 + + +class AssignUserRoleRequest(BaseModel): + """Esquema de request para asignar un rol a un usuario.""" + + user_id: str = Field(..., description="ID del usuario") + company_role_id: int = Field(..., description="ID del rol a asignar") + + +# ============================================================================ +# SCHEMAS PARA PERMISOS INDIVIDUALES DE USUARIO +# ============================================================================ + + +class UserPermissionResponse(BaseModel): + """Esquema de respuesta para un permiso individual de usuario.""" + + id: int + user_id: str + permission_id: int + company_id: int + tenant_id: int + is_granted: bool = Field(..., description="True = permiso concedido, False = permiso revocado") + is_active: bool + assigned_by: Optional[str] = None + expires_at: Optional[datetime] = None + created_at: datetime + updated_at: datetime + permission: Optional[PermissionResponse] = None + + model_config = ConfigDict(from_attributes=True) + + +class AssignUserPermissionRequest(BaseModel): + """Esquema de request para asignar un permiso individual a un usuario.""" + + permission_id: int = Field(..., description="ID del permiso") + is_granted: bool = Field(True, description="True para conceder, False para revocar") + expires_at: Optional[datetime] = Field(None, description="Fecha de expiración (opcional)") + + +class UserPermissionsListResponse(BaseModel): + """Lista de permisos individuales de un usuario.""" + + items: List[UserPermissionResponse] + total: int + + +class EffectiveUserPermissionsResponse(BaseModel): + """Permisos efectivos de un usuario (roles + individuales - revocados).""" + + user_id: str + company_id: int + role_permissions: List[PermissionResponse] = Field( + default_factory=list, description="Permisos heredados de roles" + ) + granted_permissions: List[PermissionResponse] = Field( + default_factory=list, description="Permisos individuales concedidos" + ) + revoked_permissions: List[PermissionResponse] = Field( + default_factory=list, description="Permisos revocados explícitamente" + ) + effective_permissions: List[PermissionResponse] = Field( + default_factory=list, description="Permisos finales efectivos" + ) diff --git a/backend/api/v1/modules/core/permissions/service.py b/backend/api/v1/modules/core/permissions/service.py new file mode 100644 index 0000000..b31c8d0 --- /dev/null +++ b/backend/api/v1/modules/core/permissions/service.py @@ -0,0 +1,585 @@ +""" +Servicio de gestión de permisos multi-tenant. +Proporciona funciones para verificar y obtener permisos de usuarios por companye. +""" + +import logging +from datetime import datetime +from typing import Set, Optional, List + +from sqlalchemy.orm import Session +from sqlalchemy import and_, or_ + +from core.database import RLS_TENANT_KEY +from .cache import PermissionCache +from .models import ( + Permission, + CompanyRole, + RolePermission, + UserCompanyRole, + UserCompanyPermission, +) + +logger = logging.getLogger(__name__) + + +class PermissionService: + """ + Servicio para gestionar permisos de usuarios en contextos multi-tenant. + Combina permisos de roles y permisos directos del usuario. + """ + + def __init__(self, db: Session): + self.db = db + self._cache = PermissionCache() + + def _ensure_user_tenant_row_for_company( + self, user_id: str, company_id: int + ) -> None: + """STUB — implementa con el modelo de compañía de tu proyecto.""" + + def _resolve_tenant_id_for_company(self, company_id: int) -> Optional[int]: + """ + Resuelve tenant_id efectivo para una compañía usando primero el contexto RLS + de la sesión y, como fallback, la tabla de compañías. + """ + tenant_id = self.db.info.get(RLS_TENANT_KEY) + if tenant_id is not None: + try: + return int(tenant_id) + except (TypeError, ValueError): + return None + + try: + # Sin modelo de compañía en la plantilla — implementa la consulta aquí. + pass + except Exception as exc: + logger.warning( + "resolve_tenant_id_for_company_failed", + extra={ + "company_id": company_id, + "error": str(exc), + }, + ) + return None + + def _get_user_permissions_uncached( + self, user_id: str, company_id: int + ) -> Set[str]: + """ + Lógica de cálculo de permisos sin caché. + """ + role_permissions = self._get_permissions_from_roles(user_id, company_id) + direct_permissions = self._get_direct_permissions(user_id, company_id) + + all_permissions = role_permissions.copy() + + for perm_code, is_granted in direct_permissions.items(): + if is_granted: + all_permissions.add(perm_code) + else: + all_permissions.discard(perm_code) + + return all_permissions + + def get_user_permissions( + self, user_id: str, company_id: int, use_cache: bool = False + ) -> Set[str]: + """ + Obtiene todos los permisos de un usuario para un companye específico. + Puede usar caché de Valkey cuando use_cache=True. + """ + if not use_cache: + return self._get_user_permissions_uncached(user_id, company_id) + + tenant_id = self._resolve_tenant_id_for_company(company_id) + cache_key = self._cache.build_permissions_key(tenant_id, company_id, user_id) + + cached = self._cache.get_permissions( + cache_key, + user_id=user_id, + company_id=company_id, + tenant_id=tenant_id, + ) + if cached is not None: + return cached + + permissions = self._get_user_permissions_uncached(user_id, company_id) + self._cache.set_permissions( + cache_key, + permissions, + user_id=user_id, + company_id=company_id, + tenant_id=tenant_id, + ) + return permissions + + def _get_permissions_from_roles(self, user_id: str, company_id: int) -> Set[str]: + """ + Obtiene permisos derivados de los roles del usuario en el companye. + + Realiza un JOIN eficiente para obtener todos los permisos de los roles activos. + """ + query = ( + self.db.query(Permission.code) + .join(RolePermission, RolePermission.permission_id == Permission.id) + .join(CompanyRole, CompanyRole.id == RolePermission.company_role_id) + .join(UserCompanyRole, UserCompanyRole.company_role_id == CompanyRole.id) + .filter( + and_( + UserCompanyRole.user_id == user_id, + UserCompanyRole.company_id == company_id, + UserCompanyRole.is_active == True, + CompanyRole.is_active == True, + Permission.is_active == True, + ) + ) + ) + + results = query.all() + return {perm_code for (perm_code,) in results} + + def _get_direct_permissions(self, user_id: str, company_id: int) -> dict: + """ + Obtiene permisos directos asignados al usuario. + + Returns: + Dict con código de permiso como key y is_granted como value + { + "invoice.delete": True, # Permiso concedido + "user.delete": False # Permiso revocado explícitamente + } + """ + now = datetime.utcnow() + + query = ( + self.db.query(Permission.code, UserCompanyPermission.is_granted) + .join( + UserCompanyPermission, + UserCompanyPermission.permission_id == Permission.id, + ) + .filter( + and_( + UserCompanyPermission.user_id == user_id, + UserCompanyPermission.company_id == company_id, + UserCompanyPermission.is_active == True, + Permission.is_active == True, + or_( + UserCompanyPermission.expires_at.is_(None), + UserCompanyPermission.expires_at > now, + ), + ) + ) + ) + + results = query.all() + return {perm_code: is_granted for perm_code, is_granted in results} + + def has_permission( + self, user_id: str, company_id: int, permission_code: str + ) -> bool: + """ + Verifica si un usuario tiene un permiso específico en un companye. + + Args: + user_id: ID del usuario + company_id: ID del companye/tenant + permission_code: Código del permiso (ej: "invoice.edit") + + Returns: + True si el usuario tiene el permiso, False en caso contrario + """ + permissions = self.get_user_permissions(user_id, company_id, use_cache=True) + return permission_code in permissions + + def has_any_permission( + self, user_id: str, company_id: int, permission_codes: List[str] + ) -> bool: + """ + Verifica si el usuario tiene al menos uno de los permisos especificados. + """ + permissions = self.get_user_permissions(user_id, company_id, use_cache=True) + return any(perm in permissions for perm in permission_codes) + + def has_all_permissions( + self, user_id: str, company_id: int, permission_codes: List[str] + ) -> bool: + """ + Verifica si el usuario tiene todos los permisos especificados. + """ + permissions = self.get_user_permissions(user_id, company_id, use_cache=True) + return all(perm in permissions for perm in permission_codes) + + def get_user_roles(self, user_id: str, company_id: int) -> List[CompanyRole]: + """ + Obtiene los roles activos de un usuario en un companye. + """ + query = ( + self.db.query(CompanyRole) + .join(UserCompanyRole, UserCompanyRole.company_role_id == CompanyRole.id) + .filter( + and_( + UserCompanyRole.user_id == user_id, + UserCompanyRole.company_id == company_id, + UserCompanyRole.is_active == True, + CompanyRole.is_active == True, + ) + ) + ) + + return query.all() + + def assign_role_to_user( + self, + user_id: str, + company_id: int, + role_id: int, + assigned_by: Optional[str] = None, + ) -> UserCompanyRole: + """ + Asigna un rol a un usuario en un companye específico. + """ + # Verificar que el rol pertenece al companye + role = ( + self.db.query(CompanyRole) + .filter( + and_( + CompanyRole.id == role_id, + CompanyRole.company_id == company_id, + CompanyRole.is_active == True, + ) + ) + .first() + ) + + if not role: + raise ValueError(f"Role {role_id} not found for company {company_id}") + + # Verificar si ya existe la asignación + existing = ( + self.db.query(UserCompanyRole) + .filter( + and_( + UserCompanyRole.user_id == user_id, + UserCompanyRole.company_id == company_id, + UserCompanyRole.company_role_id == role_id, + ) + ) + .first() + ) + + if existing: + if not existing.is_active: + existing.is_active = True + existing.assigned_at = datetime.utcnow() + existing.assigned_by = assigned_by + self.db.commit() + self._ensure_user_tenant_row_for_company(user_id, company_id) + # Invalidar caché del usuario tras reactivar el rol + try: + tenant_id = self._resolve_tenant_id_for_company(company_id) + self._cache.invalidate_user(tenant_id, company_id, user_id) + except Exception: + logger.warning( + "permission_cache_invalidate_user_after_assign_role_failed", + extra={"user_id": user_id, "company_id": company_id}, + ) + return existing + self._ensure_user_tenant_row_for_company(user_id, company_id) + return existing + + # Crear nueva asignación + user_role = UserCompanyRole( + user_id=user_id, + company_id=company_id, + company_role_id=role_id, + assigned_by=assigned_by, + ) + + self.db.add(user_role) + self.db.commit() + self.db.refresh(user_role) + + self._ensure_user_tenant_row_for_company(user_id, company_id) + + # Invalidar caché del usuario tras nueva asignación de rol + try: + tenant_id = self._resolve_tenant_id_for_company(company_id) + self._cache.invalidate_user(tenant_id, company_id, user_id) + except Exception: + logger.warning( + "permission_cache_invalidate_user_after_assign_role_failed", + extra={"user_id": user_id, "company_id": company_id}, + ) + + return user_role + + def grant_direct_permission( + self, + user_id: str, + company_id: int, + permission_code: str, + assigned_by: Optional[str] = None, + expires_at: Optional[datetime] = None, + ) -> UserCompanyPermission: + """ + Concede un permiso directo a un usuario en un companye. + """ + # Obtener el permiso por código + permission = ( + self.db.query(Permission) + .filter( + and_(Permission.code == permission_code, Permission.is_active == True) + ) + .first() + ) + + if not permission: + raise ValueError(f"Permission {permission_code} not found") + + # Verificar si ya existe + existing = ( + self.db.query(UserCompanyPermission) + .filter( + and_( + UserCompanyPermission.user_id == user_id, + UserCompanyPermission.company_id == company_id, + UserCompanyPermission.permission_id == permission.id, + ) + ) + .first() + ) + + if existing: + existing.is_granted = True + existing.is_active = True + existing.assigned_at = datetime.utcnow() + existing.assigned_by = assigned_by + existing.expires_at = expires_at + self.db.commit() + self._ensure_user_tenant_row_for_company(user_id, company_id) + # Invalidar caché del usuario tras mutación + try: + tenant_id = self._resolve_tenant_id_for_company(company_id) + self._cache.invalidate_user(tenant_id, company_id, user_id) + except Exception: + # La invalidación de caché nunca debe romper la operación principal + logger.warning( + "permission_cache_invalidate_user_after_grant_failed", + extra={"user_id": user_id, "company_id": company_id}, + ) + return existing + + # Crear nuevo permiso directo + user_permission = UserCompanyPermission( + user_id=user_id, + company_id=company_id, + permission_id=permission.id, + is_granted=True, + assigned_by=assigned_by, + expires_at=expires_at, + ) + + self.db.add(user_permission) + self.db.commit() + self.db.refresh(user_permission) + + self._ensure_user_tenant_row_for_company(user_id, company_id) + + # Invalidar caché del usuario tras mutación + try: + tenant_id = self._resolve_tenant_id_for_company(company_id) + self._cache.invalidate_user(tenant_id, company_id, user_id) + except Exception: + logger.warning( + "permission_cache_invalidate_user_after_grant_failed", + extra={"user_id": user_id, "company_id": company_id}, + ) + + return user_permission + + def sync_permissions(self) -> dict: + """ + Sincroniza los permisos registrados en el registry con la base de datos. + Inserta nuevos permisos y actualiza los existentes. + """ + from .registry import registry + # IMPORTANTE: Importar seed_v2 para que se ejecute register_core_permissions() + from . import seed_v2 + + registered_permissions = registry.get_all() + synced_count = 0 + updated_count = 0 + + for p_def in registered_permissions: + # Buscar permiso existente + db_permission = self.db.query(Permission).filter(Permission.code == p_def.code).first() + + if db_permission: + # Actualizar si hay cambios + changed = False + if db_permission.description != p_def.description: + db_permission.description = p_def.description + changed = True + if db_permission.module != p_def.module: + db_permission.module = p_def.module + changed = True + if db_permission.action != p_def.action: + db_permission.action = p_def.action + changed = True + if db_permission.is_active != p_def.is_active: + db_permission.is_active = p_def.is_active + changed = True + + if changed: + updated_count += 1 + else: + # Crear nuevo + new_permission = Permission( + code=p_def.code, + description=p_def.description, + module=p_def.module, + action=p_def.action, + is_active=p_def.is_active + ) + self.db.add(new_permission) + synced_count += 1 + + self.db.commit() + + return { + "synced": synced_count, + "updated": updated_count, + "total_registered": len(registered_permissions) + } + + def bootstrap_super_admin(self, user_id: str, company_id: int) -> bool: + """ + Crea un rol de Super Administrador con todos los permisos y se lo asigna al usuario. + Diseñado para el primer inicio de una compañía o para asegurar acceso a administradores. + """ + from .models import CompanyRole, RolePermission, UserCompanyRole, Permission + import logging + logger = logging.getLogger(__name__) + + try: + # 0. Sincronizar permisos por si la tabla esta vacía + # Esto puebla la tabla 'permissions' desde el registry de código + logger.info("Bootstrap: Sincronizando catálogo de permisos desde el registry...") + from . import seed_v2 + sync_res = self.sync_permissions() + logger.info(f"Bootstrap: Sincronización completa. {sync_res.get('synced', 0)} nuevos, {sync_res.get('total_registered', 0)} totales.") + + # 1. Obtener el tenant_id (implementa con tu modelo de compañía) + tenant_id = self.db.info.get(RLS_TENANT_KEY) or 1 + + # 2. Buscar si ya existe el rol "super_admin" + admin_role = self.db.query(CompanyRole).filter( + CompanyRole.company_id == company_id, + CompanyRole.code == "super_admin" + ).first() + + if not admin_role: + logger.info(f"Bootstrap: Creando Super Administrador para usuario {user_id} (Company: {company_id})") + # Crear el rol Super Administrador si no existe + admin_role = CompanyRole( + company_id=company_id, + tenant_id=tenant_id, + name="Super Administrador", + code="super_admin", + description="Rol con acceso total al sistema (generado automáticamente)", + is_active=True + ) + self.db.add(admin_role) + self.db.flush() + + # 4. Asignar TODOS los permisos activos al rol + all_perms = self.db.query(Permission).filter(Permission.is_active == True).all() + if not all_perms: + logger.warning("Bootstrap: ¡ALERTA! No se encontraron permisos en la DB ni tras la sincronización.") + + for perm in all_perms: + role_perm = RolePermission( + company_role_id=admin_role.id, + permission_id=perm.id, + tenant_id=tenant_id, + company_id=company_id + ) + self.db.add(role_perm) + else: + logger.info(f"Bootstrap: El rol super_admin ya existe para la compañía {company_id}. Sincronizando permisos nuevos si es necesario.") + # Asegurar que el rol tenga TODOS los permisos activos (incluidos los agregados después) + all_perms = self.db.query(Permission).filter(Permission.is_active == True).all() + existing_perm_ids = { + pid + for (pid,) in self.db.query(RolePermission.permission_id).filter( + RolePermission.company_role_id == admin_role.id + ) + } + added = 0 + for perm in all_perms: + if perm.id in existing_perm_ids: + continue + role_perm = RolePermission( + company_role_id=admin_role.id, + permission_id=perm.id, + tenant_id=tenant_id, + company_id=company_id, + ) + self.db.add(role_perm) + added += 1 + if added: + logger.info( + "Bootstrap: sincronicé %s permisos nuevos en super_admin company_id=%s", + added, + company_id, + ) + + # 5. Asegurar que el usuario tenga el rol asignado + user_has_role = self.db.query(UserCompanyRole).filter( + UserCompanyRole.user_id == user_id, + UserCompanyRole.company_id == company_id, + UserCompanyRole.company_role_id == admin_role.id + ).first() + + if not user_has_role: + logger.info(f"Bootstrap: Asignando rol Super Administrador al usuario {user_id}") + user_role = UserCompanyRole( + user_id=user_id, + company_id=company_id, + tenant_id=tenant_id, + company_role_id=admin_role.id, + is_active=True, + assigned_by="SYSTEM_BOOTSTRAP" + ) + self.db.add(user_role) + self.db.commit() + # Invalida caché de permisos de la compañía y del usuario afectado + try: + self._cache.invalidate_company(company_id) + self._cache.invalidate_user(tenant_id, company_id, user_id) + except Exception: + logger.warning( + "permission_cache_invalidate_after_bootstrap_failed", + extra={"user_id": user_id, "company_id": company_id}, + ) + return True + else: + logger.info(f"Bootstrap: El usuario {user_id} ya tiene el rol asignado.") + self.db.commit() + # Invalida caché de permisos de la compañía para reflejar cambios de catálogo + try: + self._cache.invalidate_company(company_id) + except Exception: + logger.warning( + "permission_cache_invalidate_after_bootstrap_failed", + extra={"user_id": user_id, "company_id": company_id}, + ) + return False + + except Exception as e: + self.db.rollback() + logger.error(f"Bootstrap: ERROR CRÍTICO - {str(e)}") + import traceback + logger.error(traceback.format_exc()) + return False diff --git a/backend/api/v1/modules/core/permissions/sync_cli.py b/backend/api/v1/modules/core/permissions/sync_cli.py new file mode 100644 index 0000000..323dff3 --- /dev/null +++ b/backend/api/v1/modules/core/permissions/sync_cli.py @@ -0,0 +1,50 @@ +""" +Script CLI para sincronizar los permisos de la aplicación. +Útil para el bootstrap inicial cuando el endpoint /sync aún no es accesible +o cuando se desea forzar una actualización desde la consola/Docker. + +Uso: +docker exec -it python3 -m api.v1.modules.core.permissions.sync_cli +""" + +import sys +import os +import logging + +# Configurar logging básico para ver resultados en consola +logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") +logger = logging.getLogger(__name__) + +# Asegurar que el backend esté en el path +sys.path.append(os.path.abspath(".")) +sys.path.append(os.path.abspath("backend")) + +from core.database import CoreSessionLocal +from api.v1.modules.core.permissions.service import PermissionService + +def run_sync(): + """Ejecuta la lógica de sincronización modular.""" + logger.info("Iniciando Sincronización Modular de Permisos...") + + db = CoreSessionLocal() + try: + service = PermissionService(db) + result = service.sync_permissions() + + logger.info("-" * 40) + logger.info(f"Sincronización Exitosa!") + logger.info(f" - Nuevos insertados: {result['synced']}") + logger.info(f" - Existentes actualizados: {result['updated']}") + logger.info(f" - Total en Registry: {result['total_registered']}") + logger.info("-" * 40) + + except Exception as e: + logger.error(f"Error crítico durante la sincronización: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + finally: + db.close() + +if __name__ == "__main__": + run_sync() diff --git a/backend/api/v1/modules/core/router.py b/backend/api/v1/modules/core/router.py new file mode 100644 index 0000000..c479e1d --- /dev/null +++ b/backend/api/v1/modules/core/router.py @@ -0,0 +1,26 @@ +from .auth.routes import router as auth_router +from .invite_codes.routes import router as invite_codes_router +from .invites.routes import router as invites_router +from .licenses.routes import router as licenses_router +from .permissions.routes import router as permissions_router +from .tenants.routes import router as tenants_router +from .user_tenant.routes import router as user_tenant_router +from .users.routes import router as users_router +from .dashboard.routes import router as dashboard_router +from .help_center.routes import router as help_center_router +from .tasks_tracking.routes import router as tasks_tracking_router +from fastapi import APIRouter + +router = APIRouter() + +router.include_router(auth_router) +router.include_router(invites_router, prefix="/core", tags=["core / invites"]) +router.include_router(invite_codes_router, prefix="/core", tags=["core / invite-codes"]) +router.include_router(tenants_router, prefix="/core", tags=["core / tenants"]) +router.include_router(user_tenant_router, prefix="/core", tags=["core / user-tenants"]) +router.include_router(users_router, prefix="/core", tags=["core / users"]) +router.include_router(licenses_router, prefix="/core", tags=["core / licenses"]) +router.include_router(permissions_router, prefix="/core", tags=["core / permissions"]) +router.include_router(dashboard_router, prefix="/core", tags=["core / dashboard"]) +router.include_router(help_center_router, prefix="/core", tags=["core / help-center"]) +router.include_router(tasks_tracking_router, prefix="/core", tags=["core / tasks"]) diff --git a/backend/api/v1/modules/core/tasks_tracking/__init__.py b/backend/api/v1/modules/core/tasks_tracking/__init__.py new file mode 100644 index 0000000..3ffbd04 --- /dev/null +++ b/backend/api/v1/modules/core/tasks_tracking/__init__.py @@ -0,0 +1,4 @@ +from .dispatch import track_and_dispatch +from .service import TaskTrackerService + +__all__ = ["track_and_dispatch", "TaskTrackerService"] diff --git a/backend/api/v1/modules/core/tasks_tracking/dispatch.py b/backend/api/v1/modules/core/tasks_tracking/dispatch.py new file mode 100644 index 0000000..20d1ace --- /dev/null +++ b/backend/api/v1/modules/core/tasks_tracking/dispatch.py @@ -0,0 +1,75 @@ +from typing import Any +import logging + +from celery import Task +from sqlalchemy.orm import Session + +from core.database import rls_company_var, rls_tenant_var + +from .service import TaskTrackerService + +logger = logging.getLogger(__name__) + + +def track_and_dispatch( + *, + db: Session, + task: Task, + tenant_id: int, + task_name: str, + task_group: str, + company_id: int | None = None, + requested_by_user: str | None = None, + task_origin: str | None = None, + args: list[Any] | None = None, + kwargs: dict[str, Any] | None = None, + task_id: str | None = None, + meta_payload: dict[str, Any] | None = None, +): + # Propaga contexto RLS vía Celery headers (leídos en task_prerun) y + # ContextVars (para modo eager, donde before_task_publish no dispara). + headers = {"rls_tenant_id": str(int(tenant_id))} + if company_id is not None: + headers["rls_company_id"] = str(int(company_id)) + else: + logger.warning( + "Dispatching task without company_id in RLS headers task=%s tenant_id=%s", + getattr(task, "name", ""), + tenant_id, + ) + + prev_tenant = rls_tenant_var.get() + prev_company = rls_company_var.get() + rls_tenant_var.set(int(tenant_id)) + rls_company_var.set(int(company_id) if company_id is not None else None) + try: + logger.info( + "Dispatching Celery task task=%s task_id=%s tenant_id=%s company_id=%s headers=%s", + getattr(task, "name", ""), + task_id, + tenant_id, + company_id, + headers, + ) + celery_task = task.apply_async( + args=args or [], + kwargs=kwargs or {}, + task_id=task_id, + headers=headers, + ) + finally: + rls_tenant_var.set(prev_tenant) + rls_company_var.set(prev_company) + + tracker = TaskTrackerService(db) + tracker.register_dispatch( + task_id=celery_task.id, + tenant_id=tenant_id, + company_id=company_id, + requested_by_user=requested_by_user, + task_name=task_name, + task_group=task_group, + task_origin=task_origin, + meta_payload=meta_payload, + ) + return celery_task diff --git a/backend/api/v1/modules/core/tasks_tracking/models.py b/backend/api/v1/modules/core/tasks_tracking/models.py new file mode 100644 index 0000000..72f9ff7 --- /dev/null +++ b/backend/api/v1/modules/core/tasks_tracking/models.py @@ -0,0 +1,62 @@ +import enum +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, func +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from core.database import Base + + +class TaskStatus(str, enum.Enum): + PENDING = "pending" + ACTIVE = "active" + COMPLETED = "completed" + FAILED = "failed" + + +class TaskRun(Base): + __tablename__ = "task_runs" + __table_args__ = ( + Index("ix_core_task_runs_task_id", "task_id", unique=True), + Index("ix_core_task_runs_tenant_updated", "tenant_id", "updated_at"), + Index("ix_core_task_runs_tenant_status_updated", "tenant_id", "status", "updated_at"), + Index("ix_core_task_runs_tenant_group_updated", "tenant_id", "task_group", "updated_at"), + Index("ix_core_task_runs_tenant_company_updated", "tenant_id", "company_id", "updated_at"), + {"schema": "core"}, + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + task_id: Mapped[str] = mapped_column(String(255), nullable=False) + tenant_id: Mapped[int] = mapped_column( + Integer, ForeignKey("core.tenants.id"), nullable=False, index=True + ) + company_id: Mapped[int | None] = mapped_column( + Integer, nullable=True, index=True + ) + requested_by_user: Mapped[str | None] = mapped_column(String(255), nullable=True) + task_name: Mapped[str] = mapped_column(String(255), nullable=False) + task_group: Mapped[str] = mapped_column(String(100), nullable=False) + task_origin: Mapped[str | None] = mapped_column(String(255), nullable=True) + status: Mapped[TaskStatus] = mapped_column( + String(20), nullable=False, default=TaskStatus.PENDING.value + ) + celery_state_raw: Mapped[str] = mapped_column(String(30), nullable=False, default="PENDING") + progress_current: Mapped[int | None] = mapped_column(Integer, nullable=True) + progress_total: Mapped[int | None] = mapped_column(Integer, nullable=True) + progress_percent: Mapped[float | None] = mapped_column(nullable=True) + progress_message: Mapped[str | None] = mapped_column(String(500), nullable=True) + retries: Mapped[int | None] = mapped_column(Integer, nullable=True) + exception_type: Mapped[str | None] = mapped_column(String(255), nullable=True) + exception_message: Mapped[str | None] = mapped_column(Text, nullable=True) + traceback_excerpt: Mapped[str | None] = mapped_column(Text, nullable=True) + result_summary: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + meta_payload: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() + ) diff --git a/backend/api/v1/modules/core/tasks_tracking/routes.py b/backend/api/v1/modules/core/tasks_tracking/routes.py new file mode 100644 index 0000000..8ab99d8 --- /dev/null +++ b/backend/api/v1/modules/core/tasks_tracking/routes.py @@ -0,0 +1,149 @@ +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from core.database import get_core_db +from core.security import get_current_user, resolve_tenant_id_required + +from .models import TaskRun, TaskStatus +from .schemas import TaskCatalogsResponse, TaskRunDetail, TaskRunListItem, TaskRunsResponse, TaskSyncRequest +from .service import TaskTrackerService + +router = APIRouter() + + +def _map_row(row: TaskRun) -> TaskRunListItem: + pct = row.progress_percent + if row.status == TaskStatus.COMPLETED.value and (pct is None or pct == 0): + pct = 100.0 + return TaskRunListItem( + task_id=row.task_id, + task_name=row.task_name, + task_group=row.task_group, + task_origin=row.task_origin, + status=row.status, + celery_state_raw=row.celery_state_raw, + progress={ + "current": row.progress_current, + "total": row.progress_total, + "percent": pct, + "message": row.progress_message, + }, + retries=row.retries, + error=( + {"type": row.exception_type, "message": row.exception_message} + if row.exception_type or row.exception_message + else None + ), + tenant_id=row.tenant_id, + requested_by_user=row.requested_by_user, + started_at=row.started_at, + finished_at=row.finished_at, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + +@router.get("/tasks", response_model=TaskRunsResponse) +def list_tasks( + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=200), + status: list[str] | None = Query(None), + task_group: list[str] | None = Query(None), + task_name: list[str] | None = Query(None), + company_id: int | None = Query(None), + search: str | None = Query(None), + sync_active: bool = Query(False), + current_user: dict[str, Any] = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id = resolve_tenant_id_required(current_user) + + tracker = TaskTrackerService(db) + if sync_active: + tracker.sync_active_tasks(tenant_id=tenant_id) + + rows, total = tracker.list_tasks( + tenant_id=tenant_id, + page=page, + page_size=page_size, + status=status, + task_group=task_group, + task_name=task_name, + company_id=company_id, + search=search, + ) + items = [_map_row(row) for row in rows] + return TaskRunsResponse( + items=items, + total=total, + page=page, + page_size=page_size, + has_next=(page * page_size) < total, + ) + + +@router.get("/tasks/{task_id}", response_model=TaskRunDetail) +def get_task_detail( + task_id: str, + sync: bool = Query(True), + current_user: dict[str, Any] = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id = resolve_tenant_id_required(current_user) + + query = db.query(TaskRun).filter(TaskRun.task_id == task_id) + if tenant_id is not None: + query = query.filter(TaskRun.tenant_id == tenant_id) + row = query.first() + if not row: + raise HTTPException(status_code=404, detail="Task not found") + + tracker = TaskTrackerService(db) + if sync: + row = tracker.sync_task(row) + + item = _map_row(row) + return TaskRunDetail( + **item.model_dump(), + traceback_excerpt=row.traceback_excerpt, + result_summary=row.result_summary, + meta_payload=row.meta_payload, + ) + + +@router.post("/tasks/sync") +def sync_tasks( + body: TaskSyncRequest, + current_user: dict[str, Any] = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id = resolve_tenant_id_required(current_user) + tracker = TaskTrackerService(db) + updated = tracker.sync_active_tasks(tenant_id=tenant_id, task_ids=body.task_ids) + return {"updated": updated} + + +@router.get("/tasks/catalogs", response_model=TaskCatalogsResponse) +def get_catalogs( + current_user: dict[str, Any] = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id = resolve_tenant_id_required(current_user) + + groups_q = db.query(TaskRun.task_group) + names_q = db.query(TaskRun.task_name) + statuses_q = db.query(TaskRun.status) + if tenant_id is not None: + groups_q = groups_q.filter(TaskRun.tenant_id == tenant_id) + names_q = names_q.filter(TaskRun.tenant_id == tenant_id) + statuses_q = statuses_q.filter(TaskRun.tenant_id == tenant_id) + groups = groups_q.distinct().order_by(TaskRun.task_group).all() + names = names_q.distinct().order_by(TaskRun.task_name).all() + statuses = statuses_q.distinct().order_by(TaskRun.status).all() + return TaskCatalogsResponse( + task_groups=[g[0] for g in groups if g[0]], + task_names=[n[0] for n in names if n[0]], + statuses=[s[0] for s in statuses if s[0]], + ) diff --git a/backend/api/v1/modules/core/tasks_tracking/schemas.py b/backend/api/v1/modules/core/tasks_tracking/schemas.py new file mode 100644 index 0000000..e672dd1 --- /dev/null +++ b/backend/api/v1/modules/core/tasks_tracking/schemas.py @@ -0,0 +1,58 @@ +from datetime import datetime +from typing import Any + +from pydantic import BaseModel + + +class TaskProgress(BaseModel): + current: int | None = None + total: int | None = None + percent: float | None = None + message: str | None = None + + +class TaskError(BaseModel): + type: str | None = None + message: str | None = None + + +class TaskRunListItem(BaseModel): + task_id: str + task_name: str + task_group: str + task_origin: str | None = None + status: str + celery_state_raw: str + progress: TaskProgress + retries: int | None = None + error: TaskError | None = None + tenant_id: int + requested_by_user: str | None = None + started_at: datetime | None = None + finished_at: datetime | None = None + created_at: datetime + updated_at: datetime + + +class TaskRunDetail(TaskRunListItem): + traceback_excerpt: str | None = None + result_summary: dict[str, Any] | None = None + meta_payload: dict[str, Any] | None = None + + +class TaskRunsResponse(BaseModel): + items: list[TaskRunListItem] + total: int + page: int + page_size: int + has_next: bool + + +class TaskSyncRequest(BaseModel): + task_ids: list[str] | None = None + + +class TaskCatalogsResponse(BaseModel): + task_groups: list[str] + task_names: list[str] + statuses: list[str] diff --git a/backend/api/v1/modules/core/tasks_tracking/service.py b/backend/api/v1/modules/core/tasks_tracking/service.py new file mode 100644 index 0000000..500b71a --- /dev/null +++ b/backend/api/v1/modules/core/tasks_tracking/service.py @@ -0,0 +1,246 @@ +from datetime import datetime, timezone +from typing import Any + +from celery.result import AsyncResult +from sqlalchemy import asc, desc, func, or_ +from sqlalchemy.orm import Session + + +from .models import TaskRun, TaskStatus + +# No usar valid_rows/total_rows del resultado final: en SUCCESS distorsiona el % (errores de scan). +_CELERY_TERMINAL_RAW = frozenset({"SUCCESS", "FAILURE", "REVOKED", "REJECTED"}) + + +def _progress_from_row_count_dict(d: dict[str, Any]) -> tuple[int, int] | None: + total = d.get("total_rows") + if not isinstance(total, (int, float)) or total <= 0: + return None + valid = d.get("valid_rows") + if isinstance(valid, (int, float)): + return int(valid), int(total) + processed = d.get("processed_rows") + if isinstance(processed, (int, float)): + return int(processed), int(total) + return None + + +def normalize_celery_state(state: str | None) -> TaskStatus: + raw = (state or "PENDING").upper() + if raw in {"STARTED", "PROGRESS", "PROCESSING", "RETRY"}: + return TaskStatus.ACTIVE + if raw == "SUCCESS": + return TaskStatus.COMPLETED + if raw in {"FAILURE", "REVOKED", "REJECTED"}: + return TaskStatus.FAILED + return TaskStatus.PENDING + + +def _extract_progress(result: AsyncResult, raw_state_upper: str) -> tuple[int | None, int | None, float | None, str | None]: + payload = result.info if isinstance(result.info, dict) else {} + current = payload.get("current") + total = payload.get("total") + message = payload.get("status") + + if current is None and isinstance(result.result, dict): + current = result.result.get("current") + if total is None and isinstance(result.result, dict): + total = result.result.get("total") + if message is None and isinstance(result.result, dict): + message = result.result.get("status") + + percent = None + if isinstance(current, (int, float)) and isinstance(total, (int, float)) and total > 0: + percent = min(100.0, max(0.0, (float(current) / float(total)) * 100.0)) + + raw = (raw_state_upper or "PENDING").upper() + if percent is None and raw not in _CELERY_TERMINAL_RAW: + for src in (payload, result.result if isinstance(result.result, dict) else {}): + if not isinstance(src, dict): + continue + pair = _progress_from_row_count_dict(src) + if pair is None: + continue + cur_i, tot_i = pair + current, total = cur_i, tot_i + percent = min(100.0, max(0.0, (float(cur_i) / float(tot_i)) * 100.0)) + break + + return ( + int(current) if isinstance(current, (int, float)) else None, + int(total) if isinstance(total, (int, float)) else None, + percent, + str(message) if message is not None else None, + ) + + +def _extract_failure(result: AsyncResult) -> tuple[str | None, str | None, str | None]: + exception_type = None + exception_message = None + traceback_excerpt = None + + err = result.result + if isinstance(err, Exception): + exception_type = type(err).__name__ + exception_message = str(err) + elif isinstance(err, dict): + exception_type = err.get("exc_type") + exception_message = err.get("exc_message") or err.get("error") or err.get("message") + elif err is not None: + exception_message = str(err) + + tb = getattr(result, "traceback", None) + if isinstance(tb, str): + traceback_excerpt = tb[-4000:] + + return exception_type, exception_message, traceback_excerpt + + +class TaskTrackerService: + def __init__(self, db: Session): + self.db = db + + def register_dispatch( + self, + *, + task_id: str, + tenant_id: int, + task_name: str, + task_group: str, + company_id: int | None = None, + requested_by_user: str | None = None, + task_origin: str | None = None, + meta_payload: dict[str, Any] | None = None, + ) -> TaskRun: + current = self.db.query(TaskRun).filter(TaskRun.task_id == task_id).first() + if current: + return current + + row = TaskRun( + task_id=task_id, + tenant_id=tenant_id, + company_id=company_id, + requested_by_user=requested_by_user, + task_name=task_name, + task_group=task_group, + task_origin=task_origin, + status=TaskStatus.PENDING.value, + celery_state_raw="PENDING", + progress_current=0, + progress_total=100, + progress_percent=0.0, + progress_message="Queued", + retries=0, + meta_payload=meta_payload, + started_at=None, + finished_at=None, + ) + self.db.add(row) + self.db.commit() + self.db.refresh(row) + return row + + def sync_task(self, task_run: TaskRun) -> TaskRun: + from core.celery_app import celery_app + async_result = celery_app.AsyncResult(task_run.task_id) + raw_state = (async_result.state or "PENDING").upper() + normalized = normalize_celery_state(raw_state) + now = datetime.now(timezone.utc) + + task_run.celery_state_raw = raw_state + task_run.status = normalized.value + task_run.retries = int(getattr(async_result, "retries", 0) or 0) + + current, total, percent, message = _extract_progress(async_result, raw_state) + if current is not None: + task_run.progress_current = current + if total is not None: + task_run.progress_total = total + if percent is not None: + task_run.progress_percent = percent + if message: + task_run.progress_message = message + + if normalized == TaskStatus.ACTIVE and task_run.started_at is None: + task_run.started_at = now + + if normalized == TaskStatus.COMPLETED: + if task_run.started_at is None: + task_run.started_at = now + task_run.finished_at = now + task_run.exception_type = None + task_run.exception_message = None + task_run.traceback_excerpt = None + if isinstance(async_result.result, dict): + task_run.result_summary = async_result.result + else: + task_run.result_summary = {"result": str(async_result.result)} + # register_dispatch seeds progress_percent=0; Celery SUCCESS often has no current/total meta + if percent is None: + task_run.progress_percent = 100.0 + + if normalized == TaskStatus.FAILED: + if task_run.started_at is None: + task_run.started_at = now + task_run.finished_at = now + etype, emsg, tb = _extract_failure(async_result) + task_run.exception_type = etype + task_run.exception_message = emsg + task_run.traceback_excerpt = tb + + self.db.add(task_run) + self.db.commit() + self.db.refresh(task_run) + return task_run + + def sync_active_tasks(self, tenant_id: int | None, task_ids: list[str] | None = None) -> int: + query = self.db.query(TaskRun).filter( + TaskRun.status.in_([TaskStatus.PENDING.value, TaskStatus.ACTIVE.value]) + ) + if tenant_id is not None: + query = query.filter(TaskRun.tenant_id == tenant_id) + if task_ids: + query = query.filter(TaskRun.task_id.in_(task_ids)) + rows = query.limit(200).all() + for row in rows: + self.sync_task(row) + return len(rows) + + def list_tasks( + self, + *, + tenant_id: int | None, + page: int, + page_size: int, + status: list[str] | None = None, + task_group: list[str] | None = None, + task_name: list[str] | None = None, + company_id: int | None = None, + search: str | None = None, + order: str = "desc", + ) -> tuple[list[TaskRun], int]: + query = self.db.query(TaskRun) + if tenant_id is not None: + query = query.filter(TaskRun.tenant_id == tenant_id) + if status: + query = query.filter(TaskRun.status.in_(status)) + if task_group: + query = query.filter(TaskRun.task_group.in_(task_group)) + if task_name: + query = query.filter(TaskRun.task_name.in_(task_name)) + if company_id is not None: + # Tareas sin company_id (p. ej. reportes solo por tenant) deben seguir visibles + query = query.filter(or_(TaskRun.company_id == company_id, TaskRun.company_id.is_(None))) + if search: + term = f"%{search}%" + query = query.filter( + (TaskRun.task_id.ilike(term)) + | (TaskRun.task_name.ilike(term)) + | (TaskRun.task_origin.ilike(term)) + | (TaskRun.exception_message.ilike(term)) + ) + + total = query.with_entities(func.count(TaskRun.id)).scalar() or 0 + order_expr = asc(TaskRun.updated_at) if order == "asc" else desc(TaskRun.updated_at) + items = query.order_by(order_expr).offset((page - 1) * page_size).limit(page_size).all() + return items, total diff --git a/backend/api/v1/modules/core/tenants/__init__.py b/backend/api/v1/modules/core/tenants/__init__.py new file mode 100644 index 0000000..73aa739 --- /dev/null +++ b/backend/api/v1/modules/core/tenants/__init__.py @@ -0,0 +1,7 @@ +""" +Módulo de Tenants +""" + +from .routes import router + +__all__ = ["router"] diff --git a/backend/api/v1/modules/core/tenants/dto.py b/backend/api/v1/modules/core/tenants/dto.py new file mode 100644 index 0000000..d851a51 --- /dev/null +++ b/backend/api/v1/modules/core/tenants/dto.py @@ -0,0 +1,119 @@ +""" +DTOs (Data Transfer Objects) para módulo de tenants +Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS +""" + +from datetime import datetime +from enum import Enum +from typing import Optional + +from pydantic import BaseModel, ConfigDict, EmailStr, Field + + +class TenantTypeDTO(str, Enum): + """Tipo de tenant""" + + SHARED = "shared" + DEDICATED = "dedicated" + + +class TenantCreateDTO(BaseModel): + """DTO para crear un nuevo tenant""" + + name: str = Field( + ..., min_length=3, max_length=255, description="Nombre del tenant" + ) + slug: str = Field( + ..., min_length=3, max_length=100, description="Identificador único del tenant" + ) + keycloak_realm: str = Field( + ..., min_length=3, max_length=255, description="Nombre del realm en Keycloak" + ) + type: TenantTypeDTO = Field( + default=TenantTypeDTO.SHARED, description="Tipo de tenant" + ) + + contact_name: Optional[str] = Field( + None, max_length=255, description="Nombre de contacto" + ) + contact_email: Optional[EmailStr] = Field(None, description="Email de contacto") + contact_phone: Optional[str] = Field( + None, max_length=50, description="Teléfono de contacto" + ) + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "name": "Empresa ABC S.A. de C.V.", + "slug": "empresa-abc", + "keycloak_realm": "empresa-abc-realm", + "type": "shared", + "contact_name": "Juan Pérez", + "contact_email": "juan.perez@empresa-abc.com", + "contact_phone": "+52 55 1234 5678", + } + } + ) + + +class TenantUpdateDTO(BaseModel): + """DTO para actualizar un tenant""" + + name: Optional[str] = Field(None, min_length=3, max_length=255) + contact_name: Optional[str] = Field(None, max_length=255) + contact_email: Optional[EmailStr] = None + contact_phone: Optional[str] = Field(None, max_length=50) + is_active: Optional[bool] = None + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "name": "Empresa ABC S.A. de C.V. - Actualizado", + "contact_email": "nuevo@empresa-abc.com", + } + } + ) + + +class TenantResponseDTO(BaseModel): + """DTO para respuesta de tenant""" + + id: int + name: str + slug: str + type: TenantTypeDTO + keycloak_realm: str + contact_name: Optional[str] + contact_email: Optional[str] + contact_phone: Optional[str] + is_active: bool + created_at: datetime + updated_at: datetime + + model_config = ConfigDict( + from_attributes=True, + json_schema_extra={ + "example": { + "id": 1, + "name": "Empresa ABC S.A. de C.V.", + "slug": "empresa-abc", + "type": "shared", + "keycloak_realm": "empresa-abc-realm", + "contact_name": "Juan Pérez", + "contact_email": "juan.perez@empresa-abc.com", + "contact_phone": "+52 55 1234 5678", + "is_active": True, + "created_at": "2025-01-15T10:30:00Z", + "updated_at": "2025-01-15T10:30:00Z", + } + }, + ) + + +class TenantListResponseDTO(BaseModel): + """DTO para lista de tenants""" + + tenants: list[TenantResponseDTO] + total: int + page: int + page_size: int diff --git a/backend/api/v1/modules/core/tenants/models.py b/backend/api/v1/modules/core/tenants/models.py new file mode 100644 index 0000000..fbc6af7 --- /dev/null +++ b/backend/api/v1/modules/core/tenants/models.py @@ -0,0 +1,66 @@ +""" +Modelos ORM para gestión de tenants +""" + +import enum +from typing import TYPE_CHECKING, List + +from api.v1.common.base_models import TimestampMixin +from core.database import Base +from sqlalchemy import Boolean, Column +from sqlalchemy import Enum as SQLEnum +from sqlalchemy import Integer, String, Text +from sqlalchemy.orm import Mapped, relationship + +from api.v1.modules.core.user_tenant.models import UserTenant + + +class TenantType(enum.Enum): + """Tipo de tenant según tamaño y necesidades""" + + SHARED = "shared" # BD compartida + DEDICATED = "dedicated" # BD dedicada + + +class Tenant(Base, TimestampMixin): + """ + Modelo de Tenant - Cliente/Organización en el sistema + Cada tenant puede tener BD compartida o dedicada + """ + + __tablename__ = "tenants" + __table_args__ = {"schema": "core", "extend_existing": True} + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(255), nullable=False, index=True) + slug = Column(String(100), unique=True, nullable=False, index=True) + + # Tipo de tenant (compartido o dedicado) + type = Column( + SQLEnum(TenantType), + default=TenantType.SHARED, + server_default="SHARED", + nullable=False, + ) + + # Keycloak realm asociado + keycloak_realm = Column(String(255), nullable=False) + + # Configuración de BD dedicada (JSON string o NULL si usa BD compartida) + db_config = Column(Text, nullable=True) # JSON: {host, port, name, user, password} + + # Información de contacto + contact_name = Column(String(255)) + contact_email = Column(String(255)) + contact_phone = Column(String(50)) + + # Estado + is_active = Column(Boolean, default=True, server_default="true", nullable=False) + + # Relación con UserTenant + user_relations: Mapped[List["UserTenant"]] = relationship( + "UserTenant", back_populates="tenant" + ) + + def __repr__(self): + return f"" diff --git a/backend/api/v1/modules/core/tenants/routes.py b/backend/api/v1/modules/core/tenants/routes.py new file mode 100644 index 0000000..ef3c5a5 --- /dev/null +++ b/backend/api/v1/modules/core/tenants/routes.py @@ -0,0 +1,131 @@ +""" +Endpoints API para gestión de tenants +""" + +from core.database import get_core_db +from core.security import get_current_user, has_role +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from .dto import ( + TenantCreateDTO, + TenantListResponseDTO, + TenantResponseDTO, + TenantUpdateDTO, +) +from .service import TenantService + +router = APIRouter(prefix="/tenants") + + +@router.post("/", response_model=TenantResponseDTO, status_code=201) +async def create_tenant( + tenant_data: TenantCreateDTO, + db: Session = Depends(get_core_db), + current_user: dict = Depends(has_role("admin")), +): + """ + Crea un nuevo tenant en el sistema + + Requiere rol: admin + """ + service = TenantService(db) + return service.create_tenant(tenant_data) + + +@router.get("/", response_model=TenantListResponseDTO) +async def list_tenants( + page: int = Query(1, ge=1, description="Número de página"), + page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"), + active_only: bool = Query(False, description="Solo tenants activos"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(has_role("admin")), +): + """ + Lista todos los tenants + + Requiere rol: admin + """ + service = TenantService(db) + skip = (page - 1) * page_size + tenants = service.list_tenants(skip=skip, limit=page_size, active_only=active_only) + + # Contar total + from .models import Tenant + + query = db.query(Tenant) + if active_only: + query = query.filter(Tenant.is_active) + total = query.count() + + return TenantListResponseDTO( + tenants=tenants, total=total, page=page, page_size=page_size + ) + + +@router.get("/{tenant_id}", response_model=TenantResponseDTO) +async def get_tenant( + tenant_id: int, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Obtiene información de un tenant por ID + """ + service = TenantService(db) + tenant = service.get_tenant(tenant_id) + if not tenant: + raise HTTPException(status_code=404, detail="Tenant not found") + return tenant + + +@router.put("/{tenant_id}", response_model=TenantResponseDTO) +async def update_tenant( + tenant_id: int, + tenant_data: TenantUpdateDTO, + db: Session = Depends(get_core_db), + current_user: dict = Depends(has_role("admin")), +): + """ + Actualiza un tenant + + Requiere rol: admin + """ + service = TenantService(db) + tenant = service.update_tenant(tenant_id, tenant_data) + if not tenant: + raise HTTPException(status_code=404, detail="Tenant not found") + return tenant + + +@router.delete("/{tenant_id}", status_code=204) +async def delete_tenant( + tenant_id: int, + db: Session = Depends(get_core_db), + current_user: dict = Depends(has_role("admin")), +): + """ + Elimina (desactiva) un tenant + + Requiere rol: admin + """ + service = TenantService(db) + if not service.delete_tenant(tenant_id): + raise HTTPException(status_code=404, detail="Tenant not found") + return None + + +@router.get("/slug/{slug}", response_model=TenantResponseDTO) +async def get_tenant_by_slug( + slug: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Obtiene un tenant por su slug + """ + service = TenantService(db) + tenant = service.get_tenant_by_slug(slug) + if not tenant: + raise HTTPException(status_code=404, detail="Tenant not found") + return tenant diff --git a/backend/api/v1/modules/core/tenants/service.py b/backend/api/v1/modules/core/tenants/service.py new file mode 100644 index 0000000..112226e --- /dev/null +++ b/backend/api/v1/modules/core/tenants/service.py @@ -0,0 +1,207 @@ +""" +Capa de servicio para lógica de negocio de tenants +""" + +import json +import logging +from typing import List, Optional + +from fastapi import HTTPException +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from .dto import TenantCreateDTO, TenantResponseDTO, TenantUpdateDTO +from .models import Tenant, TenantType + +logger = logging.getLogger(__name__) + + +class TenantService: + """Servicio para gestión de tenants""" + + def __init__(self, db: Session): + self.db = db + + def create_tenant(self, tenant_data: TenantCreateDTO) -> TenantResponseDTO: + """ + Crea un nuevo tenant en el sistema + + Args: + tenant_data: Datos del tenant a crear + + Returns: + TenantResponseDTO con información del tenant creado + + Raises: + HTTPException: Si el slug o realm ya existen + """ + try: + # Verificar que no exista el slug + existing = ( + self.db.query(Tenant).filter(Tenant.slug == tenant_data.slug).first() + ) + if existing: + raise HTTPException( + status_code=400, + detail=f"Tenant with slug '{tenant_data.slug}' already exists", + ) + + # Crear tenant + db_tenant = Tenant( + name=tenant_data.name, + slug=tenant_data.slug, + keycloak_realm=tenant_data.keycloak_realm, + type=TenantType(tenant_data.type.value), + contact_name=tenant_data.contact_name, + contact_email=tenant_data.contact_email, + contact_phone=tenant_data.contact_phone, + is_active=True, + ) + + self.db.add(db_tenant) + self.db.commit() + self.db.refresh(db_tenant) + + return TenantResponseDTO.model_validate(db_tenant) + + except IntegrityError as e: + self.db.rollback() + logger.error(f"IntegrityError creating tenant: {str(e)}") + raise HTTPException( + status_code=400, detail="Tenant with this slug or realm already exists" + ) + except HTTPException: + raise + except Exception as e: + self.db.rollback() + logger.error(f"Error creating tenant: {str(e)}") + raise HTTPException(status_code=500, detail="Error creating tenant") + + def get_tenant(self, tenant_id: int) -> Optional[TenantResponseDTO]: + """ + Obtiene un tenant por ID + + Args: + tenant_id: ID del tenant + + Returns: + TenantResponseDTO o None si no existe + """ + tenant = self.db.query(Tenant).filter(Tenant.id == tenant_id).first() + if not tenant: + return None + return TenantResponseDTO.model_validate(tenant) + + def get_tenant_by_slug(self, slug: str) -> Optional[TenantResponseDTO]: + """Obtiene un tenant por slug""" + tenant = self.db.query(Tenant).filter(Tenant.slug == slug).first() + if not tenant: + return None + return TenantResponseDTO.model_validate(tenant) + + def list_tenants( + self, skip: int = 0, limit: int = 100, active_only: bool = False + ) -> List[TenantResponseDTO]: + """ + Lista todos los tenants + + Args: + skip: Número de registros a omitir + limit: Número máximo de registros a retornar + active_only: Si True, solo retorna tenants activos + + Returns: + Lista de TenantResponseDTO + """ + query = self.db.query(Tenant) + + if active_only: + query = query.filter(Tenant.is_active) + + tenants = query.offset(skip).limit(limit).all() + return [TenantResponseDTO.model_validate(t) for t in tenants] + + def update_tenant( + self, tenant_id: int, tenant_data: TenantUpdateDTO + ) -> Optional[TenantResponseDTO]: + """ + Actualiza un tenant + + Args: + tenant_id: ID del tenant a actualizar + tenant_data: Datos a actualizar + + Returns: + TenantResponseDTO actualizado o None si no existe + """ + tenant = self.db.query(Tenant).filter(Tenant.id == tenant_id).first() + if not tenant: + return None + + # Actualizar solo campos proporcionados + update_data = tenant_data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(tenant, field, value) + + try: + self.db.commit() + self.db.refresh(tenant) + return TenantResponseDTO.model_validate(tenant) + except Exception as e: + self.db.rollback() + logger.error(f"Error updating tenant {tenant_id}: {str(e)}") + raise HTTPException(status_code=500, detail="Error updating tenant") + + def delete_tenant(self, tenant_id: int) -> bool: + """ + Elimina (desactiva) un tenant + + Args: + tenant_id: ID del tenant a eliminar + + Returns: + True si se eliminó, False si no existe + """ + tenant = self.db.query(Tenant).filter(Tenant.id == tenant_id).first() + if not tenant: + return False + + # Soft delete: marcar como inactivo + tenant.is_active = False + + try: + self.db.commit() + return True + except Exception as e: + self.db.rollback() + logger.error(f"Error deleting tenant {tenant_id}: {str(e)}") + raise HTTPException(status_code=500, detail="Error deleting tenant") + + def upgrade_to_dedicated( + self, tenant_id: int, db_config: dict + ) -> Optional[TenantResponseDTO]: + """ + Actualiza un tenant de BD compartida a BD dedicada + + Args: + tenant_id: ID del tenant + db_config: Configuración de BD dedicada + + Returns: + TenantResponseDTO actualizado + """ + tenant = self.db.query(Tenant).filter(Tenant.id == tenant_id).first() + if not tenant: + return None + + tenant.type = TenantType.DEDICATED + tenant.db_config = json.dumps(db_config) + + try: + self.db.commit() + self.db.refresh(tenant) + return TenantResponseDTO.model_validate(tenant) + except Exception as e: + self.db.rollback() + logger.error(f"Error upgrading tenant {tenant_id}: {str(e)}") + raise HTTPException(status_code=500, detail="Error upgrading tenant") diff --git a/backend/api/v1/modules/core/user_tenant/dto.py b/backend/api/v1/modules/core/user_tenant/dto.py new file mode 100644 index 0000000..8c648aa --- /dev/null +++ b/backend/api/v1/modules/core/user_tenant/dto.py @@ -0,0 +1,67 @@ +""" +DTOs para gestión de relaciones usuario-tenant +""" + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field + + +class AddUserToTenantRequestDTO(BaseModel): + """Request para agregar un usuario a un tenant""" + + keycloak_user_id: str = Field(..., description="ID del usuario en Keycloak") + tenant_id: int = Field(..., description="ID del tenant") + role: Optional[str] = Field(None, description="Rol del usuario en el tenant") + + +class RemoveUserFromTenantRequestDTO(BaseModel): + """Request para eliminar un usuario de un tenant""" + + keycloak_user_id: str = Field(..., description="ID del usuario en Keycloak") + tenant_id: int = Field(..., description="ID del tenant") + soft_delete: bool = Field(True, description="Si True, desactiva. Si False, elimina") + + +class UpdateUserRoleRequestDTO(BaseModel): + """Request para actualizar el rol de un usuario en un tenant""" + + keycloak_user_id: str = Field(..., description="ID del usuario en Keycloak") + tenant_id: int = Field(..., description="ID del tenant") + role: str = Field(..., description="Nuevo rol del usuario") + + +class UserTenantResponseDTO(BaseModel): + """Response con información de relación usuario-tenant""" + + id: int + keycloak_user_id: str + tenant_id: int + is_active: bool + role: Optional[str] + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class TenantBasicInfoDTO(BaseModel): + """Información básica de un tenant""" + + id: int + name: str + slug: str + is_active: bool + keycloak_realm: str + + class Config: + from_attributes = True + + +class UserTenantsResponseDTO(BaseModel): + """Response con los tenants de un usuario""" + + keycloak_user_id: str + tenants: list[TenantBasicInfoDTO] diff --git a/backend/api/v1/modules/core/user_tenant/models.py b/backend/api/v1/modules/core/user_tenant/models.py new file mode 100644 index 0000000..d25c00b --- /dev/null +++ b/backend/api/v1/modules/core/user_tenant/models.py @@ -0,0 +1,91 @@ +""" +Modelo de relación entre usuarios (Keycloak) y tenants +""" + +from datetime import datetime +from typing import TYPE_CHECKING, Optional + +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin +from core.database import Base +from sqlalchemy import ( + Boolean, + ForeignKeyConstraint, + JSON, + String, + Text, + UniqueConstraint, + DateTime, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +if TYPE_CHECKING: + from api.v1.modules.core.tenants.models import Tenant + + +class UserTenant(Base, TenantScopedMixin, TimestampMixin): + """ + Relación muchos-a-muchos entre usuarios de Keycloak y tenants + + Un usuario puede pertenecer a múltiples tenants + Un tenant puede tener múltiples usuarios + """ + + __tablename__ = "user_tenants" + __table_args__ = ( + UniqueConstraint( + "keycloak_user_id", "tenant_id", "company_id", name="uq_user_tenant" + ), + {"schema": "core", "extend_existing": True}, + ) + + # Primary Key + id: Mapped[int] = mapped_column(primary_key=True, index=True) + + # ID del usuario en Keycloak (UUID string) + keycloak_user_id: Mapped[str] = mapped_column( + String(255), nullable=False, index=True + ) + + # Estado de la relación + is_active: Mapped[bool] = mapped_column( + Boolean, default=True, server_default="true", nullable=False + ) + + # Información adicional - Rol del usuario en este tenant (opcional) + role: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + + # Campos de perfil de usuario + avatar_url: Mapped[Optional[str]] = mapped_column( + String(500), nullable=True, comment="URL de la imagen de perfil" + ) + workspace_user_id: Mapped[Optional[str]] = mapped_column( + String(255), nullable=True, comment="User ID (sub) proveniente de Workspace" + ) + workspace_avatar_url: Mapped[Optional[str]] = mapped_column( + String(500), nullable=True, comment="Avatar URL sincronizado desde Workspace" + ) + workspace_profile_synced_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), + nullable=True, + comment="Última sincronización de perfil con Workspace", + ) + # Caché local de nombre/apellido (fuente de verdad = Keycloak vía Hub; + # se sincroniza al editar perfil desde Anexo76) + first_name: Mapped[Optional[str]] = mapped_column( + String(100), nullable=True, comment="Nombre (caché local de Keycloak)" + ) + last_name: Mapped[Optional[str]] = mapped_column( + String(100), nullable=True, comment="Apellido (caché local de Keycloak)" + ) + phone: Mapped[Optional[str]] = mapped_column( + String(20), nullable=True, comment="Teléfono del usuario" + ) + bio: Mapped[Optional[str]] = mapped_column( + Text, nullable=True, comment="Biografía del usuario" + ) + preferences: Mapped[Optional[dict]] = mapped_column( + JSON, nullable=True, comment="Preferencias del usuario (tema, idioma, etc.)" + ) + + # Relación con Tenant + tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="user_relations") diff --git a/backend/api/v1/modules/core/user_tenant/routes.py b/backend/api/v1/modules/core/user_tenant/routes.py new file mode 100644 index 0000000..63cd107 --- /dev/null +++ b/backend/api/v1/modules/core/user_tenant/routes.py @@ -0,0 +1,141 @@ +""" +Rutas para gestión de relaciones usuario-tenant +""" + +from typing import List + +from core.database import get_core_db +from core.security import get_current_user +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from .dto import ( + AddUserToTenantRequestDTO, + RemoveUserFromTenantRequestDTO, + TenantBasicInfoDTO, + UpdateUserRoleRequestDTO, + UserTenantResponseDTO, + UserTenantsResponseDTO, +) +from .service import UserTenantService + +router = APIRouter(prefix="/user-tenants") + + +@router.post("/add", response_model=UserTenantResponseDTO) +def add_user_to_tenant( + data: AddUserToTenantRequestDTO, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Agrega un usuario a un tenant + + Requiere permisos de administrador + """ + service = UserTenantService(db) + result = service.add_user_to_tenant( + keycloak_user_id=data.keycloak_user_id, tenant_id=data.tenant_id, role=data.role + ) + return result + + +@router.post("/remove") +def remove_user_from_tenant( + data: RemoveUserFromTenantRequestDTO, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Elimina un usuario de un tenant + + Requiere permisos de administrador + """ + service = UserTenantService(db) + service.remove_user_from_tenant( + keycloak_user_id=data.keycloak_user_id, + tenant_id=data.tenant_id, + soft_delete=data.soft_delete, + ) + return {"message": "User removed from tenant successfully"} + + +@router.put("/update-role", response_model=UserTenantResponseDTO) +def update_user_role( + data: UpdateUserRoleRequestDTO, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Actualiza el rol de un usuario en un tenant + + Requiere permisos de administrador + """ + service = UserTenantService(db) + result = service.update_user_role_in_tenant( + keycloak_user_id=data.keycloak_user_id, tenant_id=data.tenant_id, role=data.role + ) + return result + + +@router.get("/user/{keycloak_user_id}", response_model=UserTenantsResponseDTO) +def get_user_tenants( + keycloak_user_id: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Obtiene todos los tenants a los que tiene acceso un usuario + + Los usuarios solo pueden ver sus propios tenants, a menos que sean admin + """ + # Verificar que el usuario solo pueda ver sus propios tenants (excepto admin) + if current_user.get("sub") != keycloak_user_id: + # TODO: Verificar si es admin + raise HTTPException( + status_code=403, detail="You can only view your own tenants" + ) + + service = UserTenantService(db) + tenants = service.get_user_tenants(keycloak_user_id) + + return UserTenantsResponseDTO( + keycloak_user_id=keycloak_user_id, + tenants=[TenantBasicInfoDTO.model_validate(t) for t in tenants], + ) + + +@router.get("/tenant/{tenant_id}", response_model=List[UserTenantResponseDTO]) +def get_tenant_users( + tenant_id: int, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Obtiene todos los usuarios que tienen acceso a un tenant + + Requiere permisos de administrador del tenant + """ + service = UserTenantService(db) + user_tenants = service.get_tenant_users(tenant_id) + return user_tenants + + +@router.get("/check-access/{keycloak_user_id}/{tenant_id}") +def check_user_access( + keycloak_user_id: str, + tenant_id: int, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Verifica si un usuario tiene acceso a un tenant + """ + service = UserTenantService(db) + has_access = service.user_has_access_to_tenant(keycloak_user_id, tenant_id) + + return { + "keycloak_user_id": keycloak_user_id, + "tenant_id": tenant_id, + "has_access": has_access, + } diff --git a/backend/api/v1/modules/core/user_tenant/service.py b/backend/api/v1/modules/core/user_tenant/service.py new file mode 100644 index 0000000..d474a67 --- /dev/null +++ b/backend/api/v1/modules/core/user_tenant/service.py @@ -0,0 +1,225 @@ +""" +Servicio para gestionar relaciones entre usuarios y tenants +""" + +import logging +from typing import List, Optional + +from fastapi import HTTPException +from sqlalchemy import and_ +from sqlalchemy.orm import Session + +from ..tenants.models import Tenant +from .models import UserTenant + +logger = logging.getLogger(__name__) + + +class UserTenantService: + """Servicio para gestionar acceso de usuarios a tenants""" + + def __init__(self, db: Session): + self.db = db + + def add_user_to_tenant( + self, keycloak_user_id: str, tenant_id: int, role: Optional[str] = None + ) -> UserTenant: + """ + Agrega un usuario a un tenant + + Args: + keycloak_user_id: ID del usuario en Keycloak + tenant_id: ID del tenant + role: Rol opcional del usuario en este tenant + + Returns: + UserTenant creado + """ + # Verificar que el tenant existe + tenant = self.db.query(Tenant).filter(Tenant.id == tenant_id).first() + if not tenant: + raise HTTPException(status_code=404, detail="Tenant not found") + + # Verificar si la relación ya existe + existing = ( + self.db.query(UserTenant) + .filter( + and_( + UserTenant.keycloak_user_id == keycloak_user_id, + UserTenant.tenant_id == tenant_id, + ) + ) + .first() + ) + + if existing: + # Si existe pero está inactiva, reactivarla + if not existing.is_active: + existing.is_active = True + existing.role = role + self.db.commit() + self.db.refresh(existing) + return existing + else: + raise HTTPException( + status_code=409, detail="User already has access to this tenant" + ) + + # Crear nueva relación + user_tenant = UserTenant( + keycloak_user_id=keycloak_user_id, + tenant_id=tenant_id, + role=role, + is_active=True, + ) + + self.db.add(user_tenant) + self.db.commit() + self.db.refresh(user_tenant) + return user_tenant + + def remove_user_from_tenant( + self, keycloak_user_id: str, tenant_id: int, soft_delete: bool = True + ) -> bool: + """ + Elimina un usuario de un tenant + + Args: + keycloak_user_id: ID del usuario en Keycloak + tenant_id: ID del tenant + soft_delete: Si True, solo marca como inactivo. Si False, elimina físicamente + + Returns: + True si se eliminó correctamente + """ + user_tenant = ( + self.db.query(UserTenant) + .filter( + and_( + UserTenant.keycloak_user_id == keycloak_user_id, + UserTenant.tenant_id == tenant_id, + ) + ) + .first() + ) + + if not user_tenant: + raise HTTPException( + status_code=404, detail="User-tenant relationship not found" + ) + + if soft_delete: + user_tenant.is_active = False + self.db.commit() + else: + self.db.delete(user_tenant) + self.db.commit() + + return True + + def get_user_tenants(self, keycloak_user_id: str) -> List[Tenant]: + """ + Obtiene todos los tenants a los que tiene acceso un usuario + + Args: + keycloak_user_id: ID del usuario en Keycloak + + Returns: + Lista de tenants + """ + user_tenants = ( + self.db.query(UserTenant) + .filter( + and_( + UserTenant.keycloak_user_id == keycloak_user_id, + UserTenant.is_active, + ) + ) + .all() + ) + + tenant_ids = [ut.tenant_id for ut in user_tenants] + + tenants = ( + self.db.query(Tenant) + .filter(and_(Tenant.id.in_(tenant_ids), Tenant.is_active)) + .all() + ) + + return tenants + + def get_tenant_users(self, tenant_id: int) -> List[UserTenant]: + """ + Obtiene todos los usuarios que tienen acceso a un tenant + + Args: + tenant_id: ID del tenant + + Returns: + Lista de relaciones UserTenant + """ + return ( + self.db.query(UserTenant) + .filter(and_(UserTenant.tenant_id == tenant_id, UserTenant.is_active)) + .all() + ) + + def user_has_access_to_tenant(self, keycloak_user_id: str, tenant_id: int) -> bool: + """ + Verifica si un usuario tiene acceso a un tenant + + Args: + keycloak_user_id: ID del usuario en Keycloak + tenant_id: ID del tenant + + Returns: + True si tiene acceso, False en caso contrario + """ + user_tenant = ( + self.db.query(UserTenant) + .filter( + and_( + UserTenant.keycloak_user_id == keycloak_user_id, + UserTenant.tenant_id == tenant_id, + UserTenant.is_active, + ) + ) + .first() + ) + + return user_tenant is not None + + def update_user_role_in_tenant( + self, keycloak_user_id: str, tenant_id: int, role: str + ) -> UserTenant: + """ + Actualiza el rol de un usuario en un tenant + + Args: + keycloak_user_id: ID del usuario en Keycloak + tenant_id: ID del tenant + role: Nuevo rol + + Returns: + UserTenant actualizado + """ + user_tenant = ( + self.db.query(UserTenant) + .filter( + and_( + UserTenant.keycloak_user_id == keycloak_user_id, + UserTenant.tenant_id == tenant_id, + ) + ) + .first() + ) + + if not user_tenant: + raise HTTPException( + status_code=404, detail="User-tenant relationship not found" + ) + + user_tenant.role = role + self.db.commit() + self.db.refresh(user_tenant) + return user_tenant diff --git a/backend/api/v1/modules/core/users/__init__.py b/backend/api/v1/modules/core/users/__init__.py new file mode 100644 index 0000000..572c6f5 --- /dev/null +++ b/backend/api/v1/modules/core/users/__init__.py @@ -0,0 +1,3 @@ +""" +Módulo de gestión de usuarios (Keycloak) +""" diff --git a/backend/api/v1/modules/core/users/dto.py b/backend/api/v1/modules/core/users/dto.py new file mode 100644 index 0000000..d472a42 --- /dev/null +++ b/backend/api/v1/modules/core/users/dto.py @@ -0,0 +1,105 @@ +""" +DTOs para gestión de usuarios de Keycloak +""" + +from datetime import datetime +from typing import List, Optional + +from pydantic import BaseModel, EmailStr, Field, field_validator + + +class CreateUserRequestDTO(BaseModel): + """Request para crear un nuevo usuario en Keycloak""" + + email: EmailStr = Field(..., description="Email del usuario") + username: str = Field( + ..., min_length=3, max_length=50, description="Nombre de usuario" + ) + first_name: str = Field(..., min_length=1, max_length=100, description="Nombre") + last_name: str = Field(..., min_length=1, max_length=100, description="Apellido") + password: str = Field(..., min_length=8, description="Contraseña temporal") + role: Optional[str] = Field(None, description="Rol del usuario en el tenant") + enabled: bool = Field(True, description="Si el usuario está habilitado") + email_verified: bool = Field(False, description="Si el email está verificado") + + +class UpdateUserRequestDTO(BaseModel): + """Request para actualizar un usuario en Keycloak""" + + first_name: Optional[str] = Field(None, max_length=100) + last_name: Optional[str] = Field(None, max_length=100) + email: Optional[str] = Field(None, max_length=255) + enabled: Optional[bool] = None + email_verified: Optional[bool] = None + role: Optional[str] = Field(None, description="Rol del usuario en el tenant actual") + + # Campos de perfil + avatar_url: Optional[str] = Field( + None, max_length=500, description="URL del avatar" + ) + phone: Optional[str] = Field(None, max_length=20, description="Teléfono") + bio: Optional[str] = Field(None, description="Biografía") + preferences: Optional[dict] = Field(None, description="Preferencias del usuario") + + @field_validator("first_name", "last_name", "email") + @classmethod + def validate_non_empty_string(cls, v: Optional[str]) -> Optional[str]: + """Valida que si el string está presente, no esté vacío""" + if v is not None and v.strip() == "": + return None # Convertir strings vacíos a None + return v + + +class UserResponseDTO(BaseModel): + """Response con información de usuario de Keycloak""" + + id: str = Field(..., description="ID de Keycloak del usuario") + username: str + email: str = Field(default="", description="Email del usuario") + first_name: str = Field(default="", description="Nombre del usuario") + last_name: str = Field(default="", description="Apellido del usuario") + enabled: bool + email_verified: bool + created_timestamp: Optional[int] = None + role: Optional[str] = Field(None, description="Rol del usuario en el tenant actual") + + # Campos de perfil + avatar_url: Optional[str] = Field(None, description="URL del avatar") + phone: Optional[str] = Field(None, description="Teléfono") + bio: Optional[str] = Field(None, description="Biografía") + preferences: Optional[dict] = Field( + default_factory=dict, description="Preferencias" + ) + + class Config: + from_attributes = True + + +class UserListResponseDTO(BaseModel): + """Response con lista de usuarios""" + + users: List[UserResponseDTO] + total: int + page: int + page_size: int + total_pages: int + + +class ChangePasswordRequestDTO(BaseModel): + """Request para cambiar contraseña de un usuario""" + + password: str = Field(..., min_length=8, description="Nueva contraseña") + temporary: bool = Field( + True, description="Si es temporal (usuario debe cambiarla al login)" + ) + + +class UserStatsDTO(BaseModel): + """Estadísticas de usuarios del tenant""" + + total_users: int + active_users: int + inactive_users: int + max_users_allowed: int + users_available: int + usage_percentage: float diff --git a/backend/api/v1/modules/core/users/routes.py b/backend/api/v1/modules/core/users/routes.py new file mode 100644 index 0000000..4dd1a10 --- /dev/null +++ b/backend/api/v1/modules/core/users/routes.py @@ -0,0 +1,478 @@ +""" +Rutas para gestión de usuarios de Keycloak +""" + +import logging +import mimetypes +from typing import Optional +import os +import uuid +from pathlib import Path + +from core.config import settings +from core.database import get_core_db +from core.s3_keys import public_user_avatar_api_path, user_avatar_key +from core.storage_s3 import delete_object_if_exists, get_object_bytes, put_object_bytes +from core.security import ( + get_current_user, + is_hub_admin, + resolve_hub_tenant_id_for_api, + validate_access_to_resource, +) +from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile +from fastapi.responses import Response +from sqlalchemy.orm import Session + +from ..user_tenant.models import UserTenant +from .dto import ( + ChangePasswordRequestDTO, + CreateUserRequestDTO, + UpdateUserRequestDTO, + UserListResponseDTO, + UserResponseDTO, + UserStatsDTO, +) +from .service import UserService + +router = APIRouter(prefix="/users", tags=["Users"]) + +logger = logging.getLogger(__name__) + +_AVATAR_EXT = {".jpg", ".jpeg", ".png", ".gif", ".webp"} + + +@router.get("/stats", response_model=UserStatsDTO) +async def get_user_statistics( + request: Request, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Obtiene estadísticas de usuarios del tenant actual + """ + tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"]) + service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user)) + auth_header = request.headers.get("Authorization") or "" + token = ( + auth_header[7:].strip() + if auth_header.lower().startswith("bearer ") + else auth_header.strip() + ) + hub_tid = resolve_hub_tenant_id_for_api( + tenant_id, request.headers.get("X-Tenant-Override") + ) + return service.get_user_stats( + access_token=token or None, + hub_tenant_id=hub_tid, + x_tenant_override=request.headers.get("X-Tenant-Override"), + ) + + +@router.get("/", response_model=UserListResponseDTO) +async def list_users( + request: Request, + company_id: int = Query(..., description="Company ID"), + page: int = Query(1, ge=1, description="Número de página"), + page_size: int = Query(20, ge=1, le=100, description="Tamaño de página"), + search: Optional[str] = Query(None, description="Término de búsqueda"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Lista todos los usuarios del tenant con paginación + """ + tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"]) + service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user)) + auth_header = request.headers.get("Authorization") or "" + token = ( + auth_header[7:].strip() + if auth_header.lower().startswith("bearer ") + else auth_header.strip() + ) + hub_tid = resolve_hub_tenant_id_for_api( + tenant_id, request.headers.get("X-Tenant-Override") + ) + result = await service.get_tenant_users( + page=page, + page_size=page_size, + search=search, + access_token=token, + hub_tenant_id=hub_tid, + x_tenant_override=request.headers.get("X-Tenant-Override"), + ) + return result + + +@router.get("/avatar/{tenant_id}/{keycloak_user_id}") +def get_user_avatar_image( + tenant_id: int, + keycloak_user_id: str, + db: Session = Depends(get_core_db), +): + """ + Sirve la imagen de avatar (público para poder usarla en sin Bearer). + El almacenamiento interno puede ser clave S3 o ruta bajo uploads/. + """ + ut = ( + db.query(UserTenant) + .filter( + UserTenant.tenant_id == tenant_id, + UserTenant.keycloak_user_id == keycloak_user_id, + UserTenant.is_active == True, + ) + .first() + ) + if not ut or not ut.avatar_url: + raise HTTPException(status_code=404, detail="Avatar not found") + + raw = ut.avatar_url + if raw.startswith("tenants/"): + try: + data = get_object_bytes(raw) + except Exception: + raise HTTPException(status_code=404, detail="Avatar not found") + media = mimetypes.guess_type(raw)[0] or "image/jpeg" + return Response(content=data, media_type=media) + + rel = raw.lstrip("/") + path = Path(rel) + if not path.is_file(): + path = Path.cwd() / rel + if not path.is_file(): + alt = Path("/app") / rel + if alt.is_file(): + path = alt + if not path.is_file(): + raise HTTPException(status_code=404, detail="Avatar file not found") + data = path.read_bytes() + media = mimetypes.guess_type(str(path))[0] or "image/jpeg" + return Response(content=data, media_type=media) + + +# === Endpoints de Perfil del Usuario Actual === + + +@router.get("/me/profile", response_model=UserResponseDTO) +async def get_my_profile( + request: Request, + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """ + Obtiene el perfil completo del usuario actual + """ + keycloak_user_id = current_user.get("sub") + if not keycloak_user_id: + raise HTTPException(status_code=400, detail="User ID not found in token") + + # Obtener user_tenant para crear servicio + user_tenant = ( + db.query(UserTenant) + .filter( + UserTenant.keycloak_user_id == keycloak_user_id, + UserTenant.is_active == True, + ) + .first() + ) + + if not user_tenant: + raise HTTPException( + status_code=400, detail="User does not belong to any tenant" + ) + + service = UserService(db, user_tenant.tenant_id, user_tenant.company_id) + auth_header = request.headers.get("Authorization") or "" + access_token = ( + auth_header[7:].strip() + if auth_header.lower().startswith("bearer ") + else auth_header.strip() or None + ) + return await service.get_current_user_profile( + keycloak_user_id, + current_user=current_user, + access_token=access_token, + ) + + +@router.put("/me/profile", response_model=UserResponseDTO) +async def update_my_profile( + request: Request, + data: UpdateUserRequestDTO, + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """ + Actualiza el perfil del usuario actual. + Campos editables: first_name, last_name, phone. + Email, username y otros campos de identidad solo se cambian desde el Hub. + """ + keycloak_user_id = current_user.get("sub") + if not keycloak_user_id: + raise HTTPException(status_code=400, detail="User ID not found in token") + + # Obtener user_tenant + user_tenant = ( + db.query(UserTenant) + .filter( + UserTenant.keycloak_user_id == keycloak_user_id, + UserTenant.is_active == True, + ) + .first() + ) + + if not user_tenant: + raise HTTPException( + status_code=400, detail="User does not belong to any tenant" + ) + + # Para sesiones autenticadas vía Workspace/Hub, la foto de perfil viene del Hub + # y no debe mutarse localmente en Anexo76. + if current_user.get("sub"): + raise HTTPException( + status_code=409, + detail="Avatar is managed by Workspace for this user", + ) + + auth_header = request.headers.get("Authorization") or "" + access_token = ( + auth_header[7:].strip() + if auth_header.lower().startswith("bearer ") + else auth_header.strip() or None + ) + + service = UserService(db, user_tenant.tenant_id, user_tenant.company_id) + return await service.update_current_user_profile( + keycloak_user_id=keycloak_user_id, + current_user=current_user, + access_token=access_token, + first_name=data.first_name, + last_name=data.last_name, + phone=data.phone, + ) + + +@router.post("/me/avatar") +async def upload_my_avatar( + file: UploadFile = File(...), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """ + Sube un avatar para el usuario actual. + Con MinIO guarda en tenants/{tid}/users/{sub}/avatar.{ext} y persiste la clave en UserTenant. + Retorna URL pública para (GET /users/avatar/...). + """ + if not file.content_type or not file.content_type.startswith("image/"): + raise HTTPException(status_code=400, detail="El archivo debe ser una imagen") + + keycloak_user_id = current_user.get("sub") + if not keycloak_user_id: + raise HTTPException(status_code=400, detail="User ID not found in token") + + user_tenant = ( + db.query(UserTenant) + .filter( + UserTenant.keycloak_user_id == keycloak_user_id, + UserTenant.is_active == True, + ) + .first() + ) + if not user_tenant: + raise HTTPException( + status_code=400, detail="User does not belong to any tenant" + ) + + ext = Path(file.filename or "image.jpg").suffix.lower() or ".jpg" + if ext not in _AVATAR_EXT: + raise HTTPException( + status_code=400, + detail=f"Extensión no permitida. Use: {', '.join(sorted(_AVATAR_EXT))}", + ) + + contents = await file.read() + if len(contents) > 2 * 1024 * 1024: + raise HTTPException(status_code=400, detail="La imagen debe ser menor a 2MB") + + tenant_id = user_tenant.tenant_id + + try: + if settings.use_s3_object_storage: + if user_tenant.avatar_url and str(user_tenant.avatar_url).startswith( + "tenants/" + ): + delete_object_if_exists(str(user_tenant.avatar_url)) + key = user_avatar_key(tenant_id, keycloak_user_id, ext) + ct = file.content_type or mimetypes.guess_type(f"x{ext}")[0] or "image/jpeg" + put_object_bytes(key, contents, content_type=ct) + user_tenant.avatar_url = key + logger.info( + "User avatar stored in S3 key=%s bytes=%s", key, len(contents) + ) + else: + upload_dir = Path("uploads/avatars") + upload_dir.mkdir(parents=True, exist_ok=True) + filename = f"{keycloak_user_id}{ext}" + file_path = upload_dir / filename + with open(file_path, "wb") as f: + f.write(contents) + user_tenant.avatar_url = f"/uploads/avatars/{filename}" + + db.add(user_tenant) + db.commit() + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + db.rollback() + raise HTTPException( + status_code=500, detail=f"Error al guardar el avatar: {str(e)}" + ) from e + + public_url = public_user_avatar_api_path(tenant_id, keycloak_user_id) + return {"avatar_url": public_url} + + +@router.get("/{user_id}", response_model=UserResponseDTO) +async def get_user_detail( + user_id: str, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Obtiene información detallada de un usuario específico + """ + tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"]) + service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user)) + return await service.get_user(user_id) + + +@router.post("/", response_model=UserResponseDTO, status_code=201) +async def create_new_user( + data: CreateUserRequestDTO, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Crea un nuevo usuario a través del Hub y lo asocia al tenant + """ + tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.create"]) + service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user)) + user = await service.create_user( + email=data.email, + username=data.username, + first_name=data.first_name, + last_name=data.last_name, + password=data.password, + role=data.role, + enabled=data.enabled, + email_verified=data.email_verified, + ) + return user + + +@router.put("/{user_id}", response_model=UserResponseDTO) +async def update_user_detail( + user_id: str, + data: UpdateUserRequestDTO, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Actualiza información de un usuario + """ + tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.update"]) + service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user)) + user = await service.update_user( + user_id=user_id, + first_name=data.first_name, + last_name=data.last_name, + email=data.email, + enabled=data.enabled, + email_verified=data.email_verified, + role=data.role, + avatar_url=data.avatar_url, + phone=data.phone, + bio=data.bio, + preferences=data.preferences, + ) + return user + + +@router.get("/{user_id}/tenant-count") +async def get_user_tenant_count( + user_id: str, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Retorna en cuántos tenants está registrado el usuario. + """ + tenant_id = validate_access_to_resource( + db, company_id, current_user, required_permissions=["user.view"] + ) + service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user)) + count = service.get_user_tenant_count(user_id) + return {"tenant_count": count} + + +@router.delete("/{user_id}") +async def delete_user_route( + request: Request, + user_id: str, + company_id: int = Query(..., description="Company ID"), + soft_delete: bool = Query( + True, + description="Si es True, solo desactiva. Si es False, elimina permanentemente", + ), + scope: str = Query( + "current", + description="'current' para borrar solo del tenant activo, 'all' para borrar de todos los tenants", + ), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Elimina un usuario del tenant. + scope='current' (default): solo del tenant activo. + scope='all': de todos los tenants en los que aparece el usuario. + """ + tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.delete"]) + auth_header = request.headers.get("Authorization") or "" + token = ( + auth_header[7:].strip() + if auth_header.lower().startswith("bearer ") + else auth_header.strip() + ) + hub_tid = resolve_hub_tenant_id_for_api( + tenant_id, request.headers.get("X-Tenant-Override") + ) + service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user)) + await service.delete_user( + user_id, + soft_delete=soft_delete, + scope=scope, + access_token=token or None, + hub_tenant_id=hub_tid, + ) + return {"message": "User deleted successfully"} + + +@router.post("/{user_id}/change-password") +async def change_user_password( + user_id: str, + data: ChangePasswordRequestDTO, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Cambia la contraseña de un usuario a través del Hub + """ + tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.update"]) + service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user)) + await service.change_password(user_id, data.password, data.temporary) + return {"message": "Password changed successfully"} diff --git a/backend/api/v1/modules/core/users/service.py b/backend/api/v1/modules/core/users/service.py new file mode 100644 index 0000000..5abab26 --- /dev/null +++ b/backend/api/v1/modules/core/users/service.py @@ -0,0 +1,808 @@ +import logging +import httpx +from datetime import datetime +from typing import Any, Dict, List, Optional +from urllib.parse import urlparse + +from fastapi import HTTPException +from sqlalchemy import and_, func +from sqlalchemy.orm import Session + +from core.config import settings + +from ..licenses.models import License, LicenseStatus +from ..user_tenant.models import UserTenant + +logger = logging.getLogger(__name__) + + +def _is_valid_http_url(url: Optional[str]) -> bool: + if not url or not isinstance(url, str): + return False + parsed = urlparse(url.strip()) + return parsed.scheme in ("http", "https") and bool(parsed.netloc) + + +def _legacy_avatar_public_url(user_tenant: Optional[Any]) -> Optional[str]: + if not user_tenant or not user_tenant.avatar_url: + return None + avatar_out = str(user_tenant.avatar_url) + + if avatar_out.startswith("http://") or avatar_out.startswith("https://"): + return avatar_out if _is_valid_http_url(avatar_out) else None + + from core.s3_keys import public_user_avatar_api_path + + # Entregamos siempre el endpoint público del backend para assets locales/S3. + return public_user_avatar_api_path( + user_tenant.tenant_id, + user_tenant.keycloak_user_id, + ) + + +def _normalize_user( + user_data: Dict[str, Any], + role: Optional[str] = None, + user_tenant: Optional[Any] = None, +) -> Dict[str, Any]: + """ + Normaliza los datos de usuario al formato esperado por el DTO. + Prioridad para nombre/apellido: caché local (user_tenant) > JWT claims > campo 'name'. + """ + name_parts = (user_data.get("name") or "").split(" ", 1) + # Caché local tiene prioridad — se actualiza al guardar perfil desde Anexo76 + local_first = getattr(user_tenant, "first_name", None) if user_tenant else None + local_last = getattr(user_tenant, "last_name", None) if user_tenant else None + + normalized = { + "id": user_data.get("id") or user_data.get("sub"), + "username": user_data.get("username") or user_data.get("preferred_username", ""), + "email": user_data.get("email", ""), + "first_name": local_first or user_data.get("firstName") or user_data.get("given_name") or (name_parts[0] if name_parts else ""), + "last_name": local_last or user_data.get("lastName") or user_data.get("family_name") or (name_parts[1] if len(name_parts) > 1 else ""), + "enabled": user_data.get("enabled", True), + "email_verified": user_data.get("emailVerified") or user_data.get("email_verified", False), + "created_timestamp": user_data.get("createdTimestamp"), + "role": role, + } + + # Agregar campos de perfil si user_tenant está disponible + if user_tenant: + workspace_avatar = ( + user_tenant.workspace_avatar_url + if _is_valid_http_url(user_tenant.workspace_avatar_url) + else None + ) + legacy_avatar = _legacy_avatar_public_url(user_tenant) + avatar_out = workspace_avatar or legacy_avatar + + normalized.update( + { + "avatar_url": avatar_out, + "workspace_avatar_url": workspace_avatar, + "legacy_avatar_url": legacy_avatar, + "workspace_user_id": user_tenant.workspace_user_id, + "phone": user_tenant.phone, + "bio": user_tenant.bio, + "preferences": user_tenant.preferences or {}, + } + ) + + return normalized + + +class UserService: + """Servicio para gestionar usuarios vía Hub""" + + def __init__(self, db: Session, tenant_id: int = None, company_id: int = None, *, is_hub_admin: bool = False): + self.db = db + self.tenant_id = tenant_id + self.company_id = company_id + self.is_hub_admin = is_hub_admin + + def _get_license(self) -> License: + """Obtiene la licencia del tenant actual""" + license = ( + self.db.query(License).filter(License.tenant_id == self.tenant_id).first() + ) + + if not license: + raise HTTPException( + status_code=404, detail="License not found for this tenant" + ) + + if license.status != LicenseStatus.ACTIVE: + raise HTTPException( + status_code=403, + detail=f"License is not active. Current status: {license.status.value}", + ) + + # Verificar si la licencia está vigente + now = datetime.now(license.expires_at.tzinfo) + if license.expires_at < now: + raise HTTPException(status_code=403, detail="License has expired") + + return license + + def _check_user_limit(self) -> None: + """Verifica si se puede crear un nuevo usuario según la licencia""" + if self.is_hub_admin: + return + license = self._get_license() + + # Contar usuarios activos del tenant + active_users = ( + self.db.query(func.count(UserTenant.id)) + .filter( + and_( + UserTenant.tenant_id == self.tenant_id, + UserTenant.is_active == True, + ) + ) + .scalar() + ) + + # max_users=NULL en BD indica licencia sin cuota (ilimitada). + # Comparar con None lanzaría TypeError — salida temprana explícita. + if license.max_users is None: + return + + if active_users >= license.max_users: + raise HTTPException( + status_code=403, + detail=f"User limit reached. Your license allows {license.max_users} users. " + f"Currently active: {active_users}. Please upgrade your license.", + ) + + async def create_user( + self, + email: str, + username: str, + first_name: str, + last_name: str, + password: str, + role: Optional[str] = None, + enabled: bool = True, + email_verified: bool = False, + ) -> Dict[str, Any]: + """ + Crea un nuevo usuario a través del Hub y lo asocia localmente + """ + # Verificar límite de usuarios + self._check_user_limit() + + try: + # Mandar al Hub para creación en Keycloak + async with httpx.AsyncClient(timeout=10.0) as client: + hub_response = await client.post( + f"{settings.HUB_URL}api/v1/auth/register", + json={ + "email": email, + "username": username, + "first_name": first_name, + "last_name": last_name, + "password": password, + "tenant_slug": "default", # TODO: Get real slug if needed + } + ) + + if hub_response.status_code != 201: + logger.error(f"Hub registration failed: {hub_response.text}") + raise HTTPException(status_code=hub_response.status_code, detail="Failed to create user in Hub") + + user_data = hub_response.json() + user_id = user_data.get("user_id") + + # Obtener company_id — implementa con tu modelo de compañía si company_id es None. + if not self.company_id: + raise HTTPException(status_code=400, detail="company_id requerido") + company_id = self.company_id + + # Crear relación local + user_tenant = UserTenant( + keycloak_user_id=user_id, + tenant_id=self.tenant_id, + company_id=company_id, + role=role, + is_active=True, + ) + self.db.add(user_tenant) + self.db.commit() + + return _normalize_user(user_data, role, user_tenant) + + except Exception as e: + logger.error(f"Error creating user: {str(e)}") + self.db.rollback() + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=str(e)) + + async def _fetch_hub_tenant_users_with_info( + self, + access_token: str, + hub_tenant_id: int, + x_tenant_override: Optional[str] = None, + ) -> List[Dict[str, Any]]: + """Lista usuarios del tenant desde Aduanasoft Hub (Keycloak + user_tenants).""" + base = (settings.HUB_URL or "").rstrip("/") + url = f"{base}/api/v1/hub/user-tenants/tenant/{hub_tenant_id}/users-with-info" + headers: Dict[str, Any] = {"Authorization": f"Bearer {access_token}"} + if x_tenant_override and str(x_tenant_override).strip(): + headers["X-Tenant-Override"] = str(x_tenant_override).strip() + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url, headers=headers) + + if response.status_code == 401: + raise HTTPException(status_code=401, detail="No autorizado en el Hub") + if response.status_code == 403: + raise HTTPException( + status_code=403, detail="Sin permiso para listar usuarios del tenant en el Hub" + ) + if response.status_code >= 400: + logger.error( + "Hub users-with-info error status=%s body=%s", + response.status_code, + response.text[:500], + ) + raise HTTPException( + status_code=502, + detail="No se pudo obtener el catálogo de usuarios desde el Hub", + ) + + data = response.json() + if not isinstance(data, list): + raise HTTPException( + status_code=502, detail="Respuesta inválida del Hub al listar usuarios" + ) + return data + + async def get_tenant_users( + self, + page: int = 1, + page_size: int = 20, + search: Optional[str] = None, + *, + access_token: str, + hub_tenant_id: int, + x_tenant_override: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Usuarios del tenant: fuente de verdad Aduanasoft Hub; roles de compañía y + perfil extendido desde BD local (user_tenants / user_company_roles). + """ + try: + from ..permissions.models import UserCompanyRole + from sqlalchemy.orm import joinedload + + if not access_token or not hub_tenant_id: + raise HTTPException( + status_code=400, + detail="Token o tenant Hub requerido para listar usuarios", + ) + + hub_rows = await self._fetch_hub_tenant_users_with_info( + access_token, hub_tenant_id, x_tenant_override + ) + + user_roles_query = ( + self.db.query(UserCompanyRole) + .options(joinedload(UserCompanyRole.company_role)) + .filter( + and_( + UserCompanyRole.company_id == self.company_id, + UserCompanyRole.tenant_id == self.tenant_id, + UserCompanyRole.is_active == True, + ) + ) + ) + user_roles_map: Dict[str, List[str]] = {} + for user_role in user_roles_query.all(): + uid = user_role.user_id + if uid not in user_roles_map: + user_roles_map[uid] = [] + user_roles_map[uid].append(user_role.company_role.name) + + local_by_kc = { + ut.keycloak_user_id: ut + for ut in self.db.query(UserTenant) + .filter( + and_( + UserTenant.tenant_id == self.tenant_id, + UserTenant.company_id == self.company_id, + ) + ) + .all() + } + + needle = (search or "").strip().lower() + filtered: List[Dict[str, Any]] = [] + for u in hub_rows: + if not u.get("is_active", True): + continue + kc = u.get("keycloak_user_id") + if not kc: + continue + # Filtrar usuarios soft-deleted localmente (is_active=False en user_tenants local) + local_ut_check = local_by_kc.get(kc) + if local_ut_check is not None and not local_ut_check.is_active: + continue + if needle: + blob = " ".join( + [ + str(u.get("email") or ""), + str(u.get("username") or ""), + str(u.get("first_name") or ""), + str(u.get("last_name") or ""), + ] + ).lower() + ut_loc = local_by_kc.get(kc) + if ut_loc: + blob += f" {ut_loc.phone or ''} {ut_loc.bio or ''}".lower() + if needle not in blob: + continue + filtered.append(u) + + total = len(filtered) + offset = (page - 1) * page_size + page_rows = filtered[offset : offset + page_size] + + users: List[Dict[str, Any]] = [] + for u in page_rows: + kc = u["keycloak_user_id"] + role_names = user_roles_map.get(kc, []) + role_str = ", ".join(role_names) if role_names else u.get("role") + local_ut = local_by_kc.get(kc) + normalized_user = _normalize_user( + { + "id": kc, + "username": u.get("username") or "", + "email": u.get("email") or "", + "firstName": u.get("first_name") or "", + "lastName": u.get("last_name") or "", + "enabled": u.get("is_active", True), + "emailVerified": False, + }, + role_str, + local_ut, + ) + users.append(normalized_user) + + total_pages = max(1, (total + page_size - 1) // page_size) if total else 1 + + return { + "users": users, + "total": total, + "page": page, + "page_size": page_size, + "total_pages": total_pages, + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error getting tenant users: {str(e)}") + raise HTTPException( + status_code=500, detail=f"Error getting users: {str(e)}" + ) from e + + async def get_user(self, user_id: str) -> Dict[str, Any]: + """Obtiene un usuario específico""" + from ..permissions.models import UserCompanyRole + from sqlalchemy.orm import joinedload + + user_tenant = self.db.query(UserTenant).filter( + and_( + UserTenant.keycloak_user_id == user_id, + UserTenant.tenant_id == self.tenant_id, + UserTenant.is_active == True, + ) + ).first() + + if not user_tenant: + raise HTTPException(status_code=404, detail="User not found") + + # Roles locales + user_roles = self.db.query(UserCompanyRole).options(joinedload(UserCompanyRole.company_role)).filter( + and_( + UserCompanyRole.user_id == user_id, + UserCompanyRole.company_id == self.company_id, + UserCompanyRole.tenant_id == self.tenant_id, + UserCompanyRole.is_active == True + ) + ).all() + roles = [ur.company_role.name for ur in user_roles] + role_str = ", ".join(roles) if roles else None + + # TODO: Call Hub if more info is needed + return _normalize_user({"id": user_id}, role_str, user_tenant) + + async def update_user(self, user_id: str, **kwargs) -> Dict[str, Any]: + """Actualiza información local del usuario (e identidad vía Hub si se implementa)""" + user_tenant = self.db.query(UserTenant).filter( + and_(UserTenant.keycloak_user_id == user_id, UserTenant.tenant_id == self.tenant_id) + ).first() + + if not user_tenant: + raise HTTPException(status_code=404, detail="User not found") + + # Actualizar campos locales + for field in ["role", "avatar_url", "phone", "bio", "preferences"]: + if field in kwargs and kwargs[field] is not None: + setattr(user_tenant, field, kwargs[field]) + + self.db.commit() + self.db.refresh(user_tenant) + return _normalize_user({"id": user_id}, user_tenant.role, user_tenant) + + def get_user_tenant_count(self, user_id: str) -> int: + """Cuenta en cuántos tenants activos está registrado el usuario.""" + return ( + self.db.query(func.count(UserTenant.id)) + .filter( + UserTenant.keycloak_user_id == user_id, + UserTenant.is_active == True, + ) + .scalar() + or 0 + ) + + async def delete_user( + self, + user_id: str, + soft_delete: bool = True, + scope: str = "current", + access_token: Optional[str] = None, + hub_tenant_id: Optional[int] = None, + ) -> None: + """ + Elimina/Desactiva usuario. + scope='current': solo del tenant activo. + scope='all': de todos los tenants (útil cuando el usuario pertenece a múltiples tenants). + """ + if scope == "all": + rows = ( + self.db.query(UserTenant) + .filter(UserTenant.keycloak_user_id == user_id) + .all() + ) + if not rows: + raise HTTPException(status_code=404, detail="User not found") + now = datetime.utcnow() + # Collect unique hub_tenant_ids to notify Hub for each tenant + hub_tenant_ids = {row.tenant_id for row in rows} + for row in rows: + row.is_active = False + if not soft_delete: + row.deleted_at = now + self.db.commit() + # Propagate to Hub for every tenant the user belonged to + if access_token: + for tid in hub_tenant_ids: + await self._hub_remove_user(user_id, tid, soft_delete, access_token) + return + + # scope == "current" (default) + user_tenant = self.db.query(UserTenant).filter( + and_( + UserTenant.keycloak_user_id == user_id, + UserTenant.tenant_id == self.tenant_id, + UserTenant.company_id == self.company_id, + ) + ).first() + + if not user_tenant: + # No local record — user exists in Hub but not synced locally yet. + # Create tombstone so user is filtered from future listings. + user_tenant = UserTenant( + keycloak_user_id=user_id, + tenant_id=self.tenant_id, + company_id=self.company_id, + is_active=False, + deleted_at=None if soft_delete else datetime.utcnow(), + ) + self.db.add(user_tenant) + self.db.commit() + else: + user_tenant.is_active = False + if not soft_delete: + user_tenant.deleted_at = datetime.utcnow() + self.db.commit() + + # Propagate to Hub + if access_token and hub_tenant_id: + await self._hub_remove_user(user_id, hub_tenant_id, soft_delete, access_token) + + async def _hub_remove_user( + self, + user_id: str, + hub_tenant_id: int, + soft_delete: bool, + access_token: str, + ) -> None: + """Calls Hub POST /api/v1/hub/user-tenants/remove to sync the deletion.""" + base = (settings.HUB_URL or "").rstrip("/") + url = f"{base}/api/v1/hub/user-tenants/remove" + try: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.post( + url, + json={ + "keycloak_user_id": user_id, + "tenant_id": hub_tenant_id, + "soft_delete": soft_delete, + }, + headers={"Authorization": f"Bearer {access_token}"}, + ) + if resp.status_code >= 400: + logger.warning( + "Hub remove user-tenant returned %s for user %s tenant %s: %s", + resp.status_code, user_id, hub_tenant_id, resp.text[:200], + ) + except Exception as exc: + logger.error("Error calling Hub remove user-tenant: %s", exc) + # Do not raise — local deletion already committed; Hub sync is best-effort. + + async def change_password(self, user_id: str, password: str, temporary: bool = True) -> None: + """Cambia contraseña vía Hub""" + try: + async with httpx.AsyncClient(timeout=10.0) as client: + await client.post( + f"{settings.HUB_URL}api/v1/auth/change-password", + json={"user_id": user_id, "password": password, "temporary": temporary} + ) + except Exception as e: + logger.error(f"Error changing password: {e}") + raise HTTPException(status_code=500, detail="Error changing password") + + def _count_active_user_tenants_local(self) -> int: + return ( + self.db.query(func.count(UserTenant.id)) + .filter( + and_( + UserTenant.tenant_id == self.tenant_id, + UserTenant.is_active == True, + ) + ) + .scalar() + or 0 + ) + + def get_user_stats( + self, + access_token: Optional[str] = None, + hub_tenant_id: Optional[int] = None, + x_tenant_override: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Estadísticas de usuarios: cupo según licencia efectiva del Hub (verify-license) + con ``X-Tenant-Override``; activos desde users-with-info del Hub si hay token; + inactivos y fallback de conteos en BD local. + """ + max_users_allowed: Optional[int] = None # None = sin cuota (hub_admin ilimitado) + hub_max_ok = False + active_users = 0 + active_from_hub = False + + if access_token and hub_tenant_id: + base = (settings.HUB_URL or "").rstrip("/") + headers: Dict[str, Any] = {"Authorization": f"Bearer {access_token}"} + if x_tenant_override and str(x_tenant_override).strip(): + headers["X-Tenant-Override"] = str(x_tenant_override).strip() + try: + with httpx.Client(timeout=30.0) as client: + lic_resp = client.get( + f"{base}/api/v1/auth/verify-license", + headers=headers, + ) + if lic_resp.status_code == 200: + lic_body = lic_resp.json() + if lic_body.get("valid"): + raw_max = lic_body.get("max_users") + # max_users=null → hub_admin sin cuota; None indica ilimitado + max_users_allowed = int(raw_max) if raw_max is not None else None + hub_max_ok = True + + users_resp = client.get( + f"{base}/api/v1/hub/user-tenants/tenant/{hub_tenant_id}/users-with-info", + headers=headers, + ) + if users_resp.status_code == 200: + payload = users_resp.json() + if isinstance(payload, list): + active_users = sum( + 1 for row in payload if row.get("is_active", True) + ) + active_from_hub = True + else: + logger.warning( + "Hub users-with-info stats: respuesta no lista" + ) + else: + logger.warning( + "Hub users-with-info stats status=%s", + users_resp.status_code, + ) + except Exception as e: + logger.warning("Hub stats (verify-license / users-with-info): %s", e) + + if not hub_max_ok: + license = self._get_license() + max_users_allowed = license.max_users + + if not active_from_hub: + active_users = self._count_active_user_tenants_local() + + inactive_users = ( + self.db.query(func.count(UserTenant.id)) + .filter( + and_( + UserTenant.tenant_id == self.tenant_id, + UserTenant.is_active == False, + ) + ) + .scalar() + or 0 + ) + + total_users = active_users + inactive_users + # Cuando max_users_allowed es None la cuota es ilimitada (hub_admin) + users_available = ( + max(0, max_users_allowed - active_users) + if max_users_allowed is not None + else None + ) + usage_percentage = ( + (active_users / max_users_allowed * 100) if max_users_allowed else 0.0 + ) + + return { + "total_users": total_users, + "active_users": active_users, + "inactive_users": inactive_users, + "max_users_allowed": max_users_allowed, + "users_available": users_available, + "usage_percentage": round(usage_percentage, 2), + } + + async def get_current_user_profile( + self, + keycloak_user_id: str, + current_user: Dict[str, Any] = None, + access_token: Optional[str] = None, + ) -> Dict[str, Any]: + """Obtiene el perfil completo del usuario actual""" + # Use the already-verified JWT claims dict — do NOT call verify_token(uuid) + user_info = current_user or {"id": keycloak_user_id} + + # Perfil "me": sincronización con cache corto (5 min). + if access_token: + from core.workspace_profile_sync import sync_workspace_profile_for_user + + await sync_workspace_profile_for_user( + self.db, + access_token=access_token, + keycloak_user_id=keycloak_user_id, + tenant_id=self.tenant_id, + ) + + user_tenant = self.db.query(UserTenant).filter( + and_(UserTenant.keycloak_user_id == keycloak_user_id, UserTenant.is_active == True) + ).first() + + return _normalize_user(user_info, user_tenant.role if user_tenant else None, user_tenant) + + async def update_current_user_profile( + self, + keycloak_user_id: str, + current_user: Dict[str, Any] = None, + access_token: Optional[str] = None, + **kwargs, + ) -> Dict[str, Any]: + """ + Actualiza el perfil del usuario actual. + - first_name / last_name: persiste localmente en UserTenant Y sincroniza con Keycloak vía Hub. + - phone: persiste solo localmente. + """ + user_tenant = self.db.query(UserTenant).filter( + and_(UserTenant.keycloak_user_id == keycloak_user_id, UserTenant.tenant_id == self.tenant_id) + ).first() + + if not user_tenant: + raise HTTPException(status_code=404, detail="User not found") + + # Campos locales — incluye first_name/last_name como caché + for field in ["role", "avatar_url", "phone", "bio", "preferences", "first_name", "last_name"]: + if field in kwargs and kwargs[field] is not None: + setattr(user_tenant, field, kwargs[field]) + + self.db.commit() + self.db.refresh(user_tenant) + + # Sincronizar nombre/apellido con Keycloak vía Hub (best-effort) + first_name = kwargs.get("first_name") + last_name = kwargs.get("last_name") + if access_token and (first_name or last_name): + await self._hub_update_user_profile(keycloak_user_id, first_name, last_name, access_token) + + user_info = current_user or {"id": keycloak_user_id} + return _normalize_user(user_info, user_tenant.role, user_tenant) + + async def _hub_get_service_token(self) -> Optional[str]: + """Obtiene un token de la cuenta de servicio Hub para operaciones admin.""" + if not settings.HUB_ADMIN_EMAIL or not settings.HUB_ADMIN_PASSWORD: + return None + base = (settings.HUB_URL or "").rstrip("/") + try: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.post( + f"{base}/api/v1/auth/login", + json={"username": settings.HUB_ADMIN_EMAIL, "password": settings.HUB_ADMIN_PASSWORD}, + ) + if resp.status_code == 200: + data = resp.json() + return data.get("access_token") + logger.warning("Hub service account login failed status=%s", resp.status_code) + except Exception as exc: + logger.warning("Hub service account login error: %s", exc) + return None + + async def _hub_update_user_profile( + self, + user_id: str, + first_name: Optional[str], + last_name: Optional[str], + access_token: str, + ) -> None: + """Sincroniza nombre/apellido con Keycloak a través del Hub (best-effort, no bloquea). + + Intenta primero con el token del usuario. Si el Hub devuelve 403 (el usuario no + tiene rol de Hub-admin), reintenta usando la cuenta de servicio configurada en + HUB_ADMIN_EMAIL / HUB_ADMIN_PASSWORD. + """ + base = (settings.HUB_URL or "").rstrip("/") + url = f"{base}/api/v1/hub/admins/{user_id}" + payload: Dict[str, Any] = {} + if first_name: + payload["first_name"] = first_name + if last_name: + payload["last_name"] = last_name + if not payload: + return + try: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.patch( + url, + json=payload, + headers={"Authorization": f"Bearer {access_token}"}, + ) + + if resp.status_code == 403: + # El usuario no es Hub admin — reintentar con cuenta de servicio + logger.info("Hub profile sync: user token got 403, trying service account for user_id=%s", user_id) + service_token = await self._hub_get_service_token() + if service_token: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.patch( + url, + json=payload, + headers={"Authorization": f"Bearer {service_token}"}, + ) + else: + logger.warning( + "Hub profile sync skipped: no service account configured (HUB_ADMIN_EMAIL/HUB_ADMIN_PASSWORD)" + ) + return + + if resp.status_code not in (200, 204): + logger.warning( + "Hub profile sync failed status=%s body=%s", + resp.status_code, + resp.text[:300], + ) + else: + logger.info("Hub profile sync OK user_id=%s", user_id) + except Exception as exc: + logger.warning("Hub profile sync error user_id=%s: %s", user_id, exc) + diff --git a/backend/api/v1/modules/example/__init__.py b/backend/api/v1/modules/example/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/v1/modules/example/dto.py b/backend/api/v1/modules/example/dto.py new file mode 100644 index 0000000..a795983 --- /dev/null +++ b/backend/api/v1/modules/example/dto.py @@ -0,0 +1,21 @@ +from pydantic import BaseModel, ConfigDict + + +class ItemCreate(BaseModel): + name: str + description: str | None = None + + +class ItemUpdate(BaseModel): + name: str | None = None + description: str | None = None + + +class ItemResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + name: str + description: str | None + tenant_id: int + company_id: int diff --git a/backend/api/v1/modules/example/models.py b/backend/api/v1/modules/example/models.py new file mode 100644 index 0000000..3dfefb6 --- /dev/null +++ b/backend/api/v1/modules/example/models.py @@ -0,0 +1,16 @@ +from sqlalchemy import Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin +from core.database import Base + + +class Item(Base, TenantScopedMixin, TimestampMixin): + """Modelo de ejemplo — renombra y ajusta a tu entidad de negocio.""" + + __tablename__ = "example_items" + __table_args__ = {"schema": "public"} + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/backend/api/v1/modules/example/routes.py b/backend/api/v1/modules/example/routes.py new file mode 100644 index 0000000..a179b78 --- /dev/null +++ b/backend/api/v1/modules/example/routes.py @@ -0,0 +1,65 @@ +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.orm import Session + +from core.database import get_core_db +from core.security import get_current_user + +from .dto import ItemCreate, ItemResponse, ItemUpdate +from . import service + +router = APIRouter() + + +@router.get("/items", response_model=list[ItemResponse]) +def list_items( + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id = current_user["tenant_id"] + return service.get_items(db, tenant_id, company_id) + + +@router.get("/items/{item_id}", response_model=ItemResponse) +def get_item( + item_id: int, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id = current_user["tenant_id"] + return service.get_item(db, item_id, tenant_id, company_id) + + +@router.post("/items", response_model=ItemResponse, status_code=status.HTTP_201_CREATED) +def create_item( + payload: ItemCreate, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id = current_user["tenant_id"] + return service.create_item(db, payload, tenant_id, company_id) + + +@router.patch("/items/{item_id}", response_model=ItemResponse) +def update_item( + item_id: int, + payload: ItemUpdate, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id = current_user["tenant_id"] + return service.update_item(db, item_id, payload, tenant_id, company_id) + + +@router.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_item( + item_id: int, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id = current_user["tenant_id"] + service.delete_item(db, item_id, tenant_id, company_id) diff --git a/backend/api/v1/modules/example/service.py b/backend/api/v1/modules/example/service.py new file mode 100644 index 0000000..abfe756 --- /dev/null +++ b/backend/api/v1/modules/example/service.py @@ -0,0 +1,48 @@ +from fastapi import HTTPException, status +from sqlalchemy.orm import Session + +from .dto import ItemCreate, ItemUpdate +from .models import Item + + +def get_items(db: Session, tenant_id: int, company_id: int) -> list[Item]: + return ( + db.query(Item) + .filter(Item.tenant_id == tenant_id, Item.company_id == company_id, Item.deleted_at.is_(None)) + .all() + ) + + +def get_item(db: Session, item_id: int, tenant_id: int, company_id: int) -> Item: + item = ( + db.query(Item) + .filter(Item.id == item_id, Item.tenant_id == tenant_id, Item.company_id == company_id, Item.deleted_at.is_(None)) + .first() + ) + if not item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Item no encontrado") + return item + + +def create_item(db: Session, payload: ItemCreate, tenant_id: int, company_id: int) -> Item: + item = Item(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id) + db.add(item) + db.commit() + db.refresh(item) + return item + + +def update_item(db: Session, item_id: int, payload: ItemUpdate, tenant_id: int, company_id: int) -> Item: + item = get_item(db, item_id, tenant_id, company_id) + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(item, field, value) + db.commit() + db.refresh(item) + return item + + +def delete_item(db: Session, item_id: int, tenant_id: int, company_id: int) -> None: + item = get_item(db, item_id, tenant_id, company_id) + from datetime import datetime, timezone + item.deleted_at = datetime.now(timezone.utc) + db.commit() diff --git a/backend/api/v1/router.py b/backend/api/v1/router.py new file mode 100644 index 0000000..3571b0a --- /dev/null +++ b/backend/api/v1/router.py @@ -0,0 +1,20 @@ +""" +Router principal de API v1 +""" + +from fastapi import APIRouter + +from .modules.core.router import router as core_router +from .modules.example.routes import router as example_router + + +router = APIRouter() + +router.include_router(core_router) +router.include_router(example_router, prefix="/example", tags=["example"]) + + +@router.get("/status") +def status(): + """Health check de la API""" + return {"status": "ok", "version": "1.0.0", "api": "v1"} diff --git a/backend/core/__init__.py b/backend/core/__init__.py new file mode 100644 index 0000000..9739594 --- /dev/null +++ b/backend/core/__init__.py @@ -0,0 +1,41 @@ +""" +Core module - Configuración y utilidades centrales de la aplicación +""" + +from .config import settings +from .database import ( + Base, + get_async_core_db, + get_core_db, + get_tenant_db, + init_async_db, + init_db, + scoped_async_core_db, + scoped_core_db, + set_rls_context, +) +from .security import ( + get_current_active_user, + get_current_user, + get_tenant_from_token, + has_role, + verify_token, +) + +__all__ = [ + "settings", + "Base", + "get_core_db", + "get_async_core_db", + "get_tenant_db", + "scoped_core_db", + "scoped_async_core_db", + "set_rls_context", + "init_db", + "init_async_db", + "verify_token", + "get_current_user", + "get_current_active_user", + "has_role", + "get_tenant_from_token", +] diff --git a/backend/core/celery_app.py b/backend/core/celery_app.py new file mode 100644 index 0000000..08b1c58 --- /dev/null +++ b/backend/core/celery_app.py @@ -0,0 +1,126 @@ +import os +import logging +from celery import Celery +from celery.signals import task_postrun, task_prerun + +from core.database import reset_rls_context_tokens, rls_company_var, rls_tenant_var +from core.config import settings + +logger = logging.getLogger(__name__) + +valkey_url = settings.VALKEY_URL +print(f"DEBUG: Celery Broker URL: {valkey_url}") +logger.info( + "Initializing Celery app app_version=%s environment=%s broker=%s", + settings.APP_VERSION, + settings.ENVIRONMENT, + valkey_url, +) + +# Configurar broker y backend explícitamente en el constructor +celery_app = Celery( + "app_tasks", + broker=valkey_url, + backend=valkey_url, +) +celery_app.set_default() + + +_RLS_TOKENS_ATTR = "_rls_context_tokens" + + +def _coerce_int(value) -> int | None: + if value is None or value == "": + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +@task_prerun.connect +def _set_rls_context_from_task(task_id=None, task=None, args=None, kwargs=None, **_): + """Fija las ContextVars de RLS para la ejecución de la tarea. + + Las rutas propagan ``tenant_id`` / ``company_id`` vía Celery headers en + :func:`track_and_dispatch`. Aquí los materializamos en ContextVars para + que cualquier sesión que se abra durante la tarea (incluidos los helpers + ``scoped_core_db`` y llamadas directas a ``CoreSessionLocal()``) aplique + ``SET LOCAL`` automáticamente. + """ + headers = {} + request = getattr(task, "request", None) if task is not None else None + if request is not None: + headers = getattr(request, "headers", None) or {} + + tenant_id = _coerce_int(headers.get("rls_tenant_id")) + company_id = _coerce_int(headers.get("rls_company_id")) + if tenant_id is None: + logger.warning( + "Celery task_prerun missing rls_tenant_id task=%s task_id=%s headers=%s", + getattr(task, "name", ""), + task_id, + headers, + ) + logger.info( + "Celery task_prerun RLS context task=%s task_id=%s tenant_id=%s company_id=%s", + getattr(task, "name", ""), + task_id, + tenant_id, + company_id, + ) + + token_t = rls_tenant_var.set(tenant_id) + token_c = rls_company_var.set(company_id) + setattr(task, _RLS_TOKENS_ATTR, (token_t, token_c)) + + +@task_postrun.connect +def _reset_rls_context_from_task(task_id=None, task=None, **_): + """Restaura las ContextVars al terminar la tarea (evita fuga entre tareas + cuando un worker reutiliza el mismo hilo).""" + tokens = getattr(task, _RLS_TOKENS_ATTR, None) if task is not None else None + if tokens is None: + return + token_t, token_c = tokens + logger.info( + "Celery task_postrun clearing RLS context task=%s task_id=%s tenant_id=%s company_id=%s", + getattr(task, "name", ""), + task_id, + rls_tenant_var.get(), + rls_company_var.get(), + ) + reset_rls_context_tokens(token_t, token_c) + delattr(task, _RLS_TOKENS_ATTR) + +celery_app.conf.update( + include=[ + "api.v1.modules.core.help_center.tasks", + # Agrega aquí las tareas de tu proyecto: + # "api.v1.modules.example.tasks", + ] +) + +# Configuraciones adicionales +celery_app.conf.update( + task_track_started=True, + task_serializer="json", + accept_content=["json"], + result_serializer="json", + timezone="America/Mexico_City", + enable_utc=True, +) + +celery_app.conf.beat_schedule = { + "sync-from-hub-every-minute": { + "task": "sync_from_hub_task", + "schedule": 60.0, # Run every 60 seconds + }, + "cleanup-orphan-layout-imports-hourly": { + "task": "cleanup_orphan_layout_imports", + "schedule": 3600.0, + }, +} + +if __name__ == "__main__": + celery_app.start() diff --git a/backend/core/config.py b/backend/core/config.py new file mode 100644 index 0000000..ff4be27 --- /dev/null +++ b/backend/core/config.py @@ -0,0 +1,149 @@ +""" +Configuración centralizada de la aplicación usando Pydantic Settings +""" + +from typing import List, Literal +from pydantic import field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """Configuración de la aplicación""" + + # Application + APP_NAME: str = "Mi Aplicación" + # Sobreescribible con APP_VERSION (Dockerfile/Jenkins: build-arg + ENV) o entorno en runtime + APP_VERSION: str = "dev-local" + DEBUG: bool = True + ENVIRONMENT: str = "development" + + # Auth local para desarrollo (sin Keycloak/Hub) + # Nunca activar en producción. + DEV_LOCAL_AUTH: bool = False + DEV_LOCAL_AUTH_EMAIL: str = "dev@local.test" + DEV_LOCAL_AUTH_NAME: str = "Dev User" + DEV_LOCAL_AUTH_TENANT_ID: int = 1 + DEV_LOCAL_AUTH_COMPANY_ID: int = 1 + + # Database - Core (Shared) + CORE_DB_HOST: str = "postgres" + CORE_DB_PORT: int = 5432 + CORE_DB_NAME: str = "app_core" + CORE_DB_USER: str = "postgres" + CORE_DB_PASSWORD: str = "postgres" + + # Security + SECRET_KEY: str = "change-this-secret-key-in-production" + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + + # Valkey / Redis + VALKEY_URL: str = "redis://valkey:6379/0" + PERMISSION_CACHE_ENABLED: bool = True + PERMISSION_CACHE_TTL_SECONDS: int = 300 + + # Synchronization + SYNC_SECRET_TOKEN: str = "change-this-sync-token-in-production" + CENTRAL_SERVER_URL: str = "http://localhost:8000/api/v1/core/help-center/sync/" + SPOKE_URLS: str = "" # Comma separated list of Spoke URLs for Broadcast (Hub only) + + # CORS + CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000" + + # Hub de Aduanasoft — requerido siempre (SaaS y self-hosted) + HUB_URL: str = "http://localhost:8001" + # Base API del Hub/Workspace para endpoint /v1/auth/me (fuente de verdad de perfil) + HUB_API_BASE_URL: str = "" + HUB_PROFILE_SYNC_TIMEOUT_MS: int = 3000 + # Cuenta de servicio Hub — usada para operaciones admin (ej. sync de nombre a Keycloak) + HUB_ADMIN_EMAIL: str = "" + HUB_ADMIN_PASSWORD: str = "" + + # URL pública del frontend — usada en links de email (invitaciones, etc.) + APP_PUBLIC_URL: str = "http://localhost:3000" + + @field_validator("CENTRAL_SERVER_URL", "SPOKE_URLS", "HUB_URL", "HUB_API_BASE_URL", mode="before") + @classmethod + def strip_quotes(cls, v: str) -> str: + if v and isinstance(v, str): + v = v.strip().strip('"').strip("'") + # Evitar que solo espacios en .env se conviertan en "/" (rompe httpx: falta protocolo). + if not v: + return "" + if not v.endswith("/"): + v += "/" + return v + return v + + # External APIs + SITAR_API_URL: str = "api.sitar.aduanasoft.com:880" + COVE_API_URL: str = "https://api.vu.aduanasoft.com" + COVE_API_VERIFY_SSL: bool = False + COVE_FIEL_HASH_KEY: str = "" + COVE_FIEL_HASH_IV: str = "" + SITAR_API_USER: str = "" + SITAR_API_PASSWORD: str = "" + # SMTP Email Configuration + SMTP_HOST: str = "smtp.gmail.com" + SMTP_PORT: int = 587 + SMTP_USER: str = "" + SMTP_PASSWORD: str = "" + SMTP_FROM_NAME: str = "Mi Aplicación" + SMTP_USE_TLS: bool = True + + # CSV imports (layouts_csv): redis = base64 en Valkey; minio = S3 + referencia en Redis + CSV_IMPORT_STORAGE: Literal["redis", "minio"] = "minio" + S3_ENDPOINT_URL: str = "http://minio:9000" + S3_ACCESS_KEY: str = "" + S3_SECRET_KEY: str = "" + S3_BUCKET: str = "app" + S3_REGION: str = "us-east-1" + S3_USE_SSL: bool = False + # Logos, certificados, help (si no quieres MinIO aquí, pon false Y CSV_IMPORT_STORAGE=redis) + S3_FILE_STORAGE: bool = True + S3_PRESIGNED_EXPIRES_SECONDS: int = 3600 + + model_config = SettingsConfigDict( + env_file=[".env", "../.env"], + case_sensitive=True, + extra="ignore", + env_file_encoding="utf-8", + ) + + @property + def core_database_url(self) -> str: + """URL de conexión a la base de datos core""" + return f"postgresql://{self.CORE_DB_USER}:{self.CORE_DB_PASSWORD}@{self.CORE_DB_HOST}:{self.CORE_DB_PORT}/{self.CORE_DB_NAME}" + + @property + def async_core_database_url(self) -> str: + """URL de conexión asíncrona a la base de datos core""" + return f"postgresql+asyncpg://{self.CORE_DB_USER}:{self.CORE_DB_PASSWORD}@{self.CORE_DB_HOST}:{self.CORE_DB_PORT}/{self.CORE_DB_NAME}" + + @property + def cors_origins_list(self) -> List[str]: + """Lista de orígenes CORS permitidos""" + return [origin.strip() for origin in self.CORS_ORIGINS.split(",")] + + @property + def use_s3_object_storage(self) -> bool: + """ + Usar MinIO para logos, certificados y Help (mismo bucket que CSV). + True si los imports CSV ya usan MinIO o si S3_FILE_STORAGE está activo. + """ + return self.CSV_IMPORT_STORAGE == "minio" or self.S3_FILE_STORAGE + + @property + def hub_api_base_url(self) -> str: + """ + Base URL para endpoints /v1 del Workspace/Hub. + Si HUB_API_BASE_URL no está definido, deriva de HUB_URL + /api. + """ + custom = (self.HUB_API_BASE_URL or "").strip().rstrip("/") + if custom: + return custom + return f"{self.HUB_URL.rstrip('/')}/api" + + +# Instancia global de configuración +settings = Settings() diff --git a/backend/core/context.py b/backend/core/context.py new file mode 100644 index 0000000..d8cef92 --- /dev/null +++ b/backend/core/context.py @@ -0,0 +1,10 @@ +from contextvars import ContextVar +from typing import Optional, Dict, Any + +_user_context: ContextVar[Optional[Dict[str, Any]]] = ContextVar("user_context", default=None) + +def get_user_context() -> Optional[Dict[str, Any]]: + return _user_context.get() + +def set_user_context(user: Dict[str, Any]) -> None: + _user_context.set(user) diff --git a/backend/core/database.py b/backend/core/database.py new file mode 100644 index 0000000..f88412a --- /dev/null +++ b/backend/core/database.py @@ -0,0 +1,278 @@ +""" +Configuración de base de datos con soporte multi-tenant +- Base de datos compartida (core_db) para tenants pequeños/medianos +- Bases de datos dedicadas para clientes enterprise + +Row-Level Security (RLS): + Para respetar el aislamiento por tenant/company definido en PostgreSQL, + cada sesión fija las GUCs ``app.tenant_id`` y ``app.company_id`` vía + ``SET LOCAL`` al inicio de cada transacción. El listener + ``after_begin`` aplica el contexto guardado en ``Session.info``. +""" + +import logging +from contextlib import asynccontextmanager, contextmanager +from contextvars import ContextVar +from typing import AsyncGenerator, Dict, Generator, Optional + +from fastapi import Request +from sqlalchemy import create_engine, event, text +from sqlalchemy.exc import ProgrammingError +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import Session, declarative_base, sessionmaker + +from .config import settings + +logger = logging.getLogger(__name__) + +Base = declarative_base() + +core_engine = create_engine( + settings.core_database_url, + pool_pre_ping=True, + pool_size=10, + max_overflow=20, + echo=False, +) + +CoreSessionLocal = sessionmaker( + autocommit=False, autoflush=False, bind=core_engine) + +async_core_engine = create_async_engine( + settings.async_core_database_url, + pool_pre_ping=True, + pool_size=10, + max_overflow=20, + echo=settings.DEBUG, +) + +AsyncCoreSessionLocal = async_sessionmaker( + async_core_engine, class_=AsyncSession, expire_on_commit=False +) + +_tenant_engines: Dict[str, any] = {} + + +RLS_TENANT_KEY = "rls_tenant_id" +RLS_COMPANY_KEY = "rls_company_id" + +rls_tenant_var: ContextVar[Optional[int]] = ContextVar("rls_tenant_id", default=None) +rls_company_var: ContextVar[Optional[int]] = ContextVar("rls_company_id", default=None) + + +def _apply_rls_context(connection, tenant_id: Optional[int], company_id: Optional[int]) -> None: + """Ejecuta ``SET LOCAL`` en la transacción activa para fijar el contexto RLS.""" + tenant_value = "" if tenant_id is None else str(int(tenant_id)) + company_value = "" if company_id is None else str(int(company_id)) + connection.execute( + text( + "SELECT set_config('app.tenant_id', :t, true), " + "set_config('app.company_id', :c, true)" + ), + {"t": tenant_value, "c": company_value}, + ) + + +def _resolve_context(session) -> tuple[Optional[int], Optional[int]]: + """Selecciona tenant_id/company_id desde ``session.info`` y, si faltan, + desde las ContextVars (usadas por tareas Celery vía task_prerun).""" + tenant_id = session.info.get(RLS_TENANT_KEY) + company_id = session.info.get(RLS_COMPANY_KEY) + if tenant_id is None: + tenant_id = rls_tenant_var.get() + if company_id is None: + company_id = rls_company_var.get() + return tenant_id, company_id + + +@event.listens_for(Session, "after_begin") +def _after_begin(session: Session, transaction, connection) -> None: # type: ignore[no-untyped-def] + """Aplica ``SET LOCAL`` en cada transacción nueva. + + Cubre sesiones síncronas y asíncronas porque ``AsyncSession`` envuelve + internamente una ``Session`` que hereda de esta clase. + """ + tenant_id, company_id = _resolve_context(session) + if tenant_id is None and company_id is None: + return + _apply_rls_context(connection, tenant_id, company_id) + + +def set_rls_context( + session: Session, + tenant_id: Optional[int] = None, + company_id: Optional[int] = None, +) -> None: + """Guarda el contexto RLS en la sesión y, si hay transacción abierta, lo aplica. + + Útil para endpoints que validan acceso a una compañía específica después de + crear la sesión (por ejemplo rutas que reciben ``company_id`` en el path). + """ + session.info[RLS_TENANT_KEY] = tenant_id + session.info[RLS_COMPANY_KEY] = company_id + if session.in_transaction(): + _apply_rls_context(session.connection(), tenant_id, company_id) + + +def reset_rls_context_tokens(token_t, token_c) -> None: + """Restaura ContextVars de RLS de forma segura entre hilos/tareas asyncio. + + ``ContextVar.reset`` exige que el token se cree y restaure en el mismo contexto + lógico; en rutas FastAPI async + dependencias síncronas con ``yield`` (thread + pool) el ``finally`` puede ejecutarse en otro contexto y lanzar ``ValueError`` + (mensaje: "was created in a different Context"). En ese caso degradamos a + ``set(None)``, igual que ``task_postrun`` en ``core/celery_app.py``. + """ + try: + rls_tenant_var.reset(token_t) + rls_company_var.reset(token_c) + except (ValueError, RuntimeError): + rls_tenant_var.set(None) + rls_company_var.set(None) + + +def _extract_rls_context(request: Optional[Request]) -> tuple[Optional[int], Optional[int]]: + """Recupera ``tenant_id`` / ``company_id`` del estado del request (o de cookies).""" + if request is None: + return None, None + tenant_id = getattr(request.state, "tenant_id", None) + company_id = getattr(request.state, "company_id", None) + if company_id is None: + cookie_value = request.cookies.get("active_company_id") + if cookie_value: + try: + company_id = int(cookie_value) + except (TypeError, ValueError): + company_id = None + return tenant_id, company_id + + +def get_core_db(request: Request = None) -> Generator[Session, None, None]: + """Dependency para obtener sesión síncrona con contexto RLS. + + FastAPI inyecta ``Request`` automáticamente; los llamadores existentes que + escriben ``db: Session = Depends(get_core_db)`` siguen funcionando sin + cambios porque ``Request`` se resuelve en la capa de dependencia. + + No se escriben las ContextVars de RLS aquí: las dependencias síncronas con + ``yield`` se ejecutan vía ``contextmanager_in_threadpool`` (hilo worker) y + mezclar ``ContextVar.set`` / ``reset`` entre ese hilo y el bucle asyncio + provoca ``ValueError: ... was created in a different Context``. El aislamiento + RLS se aplica con ``session.info`` (véase ``after_begin`` y audit listeners). + """ + tenant_id, company_id = _extract_rls_context(request) + db = CoreSessionLocal() + db.info[RLS_TENANT_KEY] = tenant_id + db.info[RLS_COMPANY_KEY] = company_id + try: + yield db + finally: + db.close() + + +async def get_async_core_db(request: Request = None) -> AsyncGenerator[AsyncSession, None]: + """Dependency async para obtener sesión con contexto RLS.""" + tenant_id, company_id = _extract_rls_context(request) + prev_tenant = rls_tenant_var.get() + prev_company = rls_company_var.get() + rls_tenant_var.set(tenant_id) + rls_company_var.set(company_id) + try: + async with AsyncCoreSessionLocal() as session: + session.info[RLS_TENANT_KEY] = tenant_id + session.info[RLS_COMPANY_KEY] = company_id + try: + yield session + finally: + await session.close() + finally: + rls_tenant_var.set(prev_tenant) + rls_company_var.set(prev_company) + + +@contextmanager +def scoped_core_db( + tenant_id: Optional[int] = None, + company_id: Optional[int] = None, +) -> Generator[Session, None, None]: + """Abre una sesión síncrona con contexto RLS explícito. + + Pensado para tareas Celery, comandos de mantenimiento o cualquier camino + fuera del ciclo de request HTTP. El contexto se aplica con ``SET LOCAL`` + en cada transacción. + """ + db = CoreSessionLocal() + db.info[RLS_TENANT_KEY] = tenant_id + db.info[RLS_COMPANY_KEY] = company_id + try: + yield db + finally: + db.close() + + +@asynccontextmanager +async def scoped_async_core_db( + tenant_id: Optional[int] = None, + company_id: Optional[int] = None, +) -> AsyncGenerator[AsyncSession, None]: + """Variante async de :func:`scoped_core_db`.""" + async with AsyncCoreSessionLocal() as session: + session.info[RLS_TENANT_KEY] = tenant_id + session.info[RLS_COMPANY_KEY] = company_id + try: + yield session + finally: + await session.close() + + +def get_tenant_engine(tenant_id: int, db_config: dict): + """Obtiene o crea un engine para un tenant con BD dedicada.""" + if tenant_id not in _tenant_engines: + db_url = f"postgresql://{db_config['user']}:{db_config['password']}@{db_config['host']}:{db_config['port']}/{db_config['name']}" + _tenant_engines[tenant_id] = create_engine( + db_url, pool_pre_ping=True, pool_size=5, max_overflow=10 + ) + return _tenant_engines[tenant_id] + + +@contextmanager +def get_tenant_db( + tenant_id: int, db_config: Optional[dict] = None +) -> Generator[Session, None, None]: + """Context manager para obtener sesión de BD de un tenant específico. + + Si ``db_config`` es ``None`` usa la BD core compartida y aplica RLS con + el ``tenant_id`` recibido. Si el tenant tiene BD dedicada, el aislamiento + es físico y no se fija contexto RLS (no hay columna ``tenant_id``). + """ + if db_config is None: + db = CoreSessionLocal() + db.info[RLS_TENANT_KEY] = tenant_id + else: + engine = get_tenant_engine(tenant_id, db_config) + SessionLocal = sessionmaker( + autocommit=False, autoflush=False, bind=engine) + db = SessionLocal() + + try: + yield db + finally: + db.close() + + +def init_db(): + """Inicializa las tablas de la base de datos core.""" + try: + Base.metadata.create_all(bind=core_engine, checkfirst=True) + except ProgrammingError as e: + if "already exists" in str(e): + logger.warning( + f"Algunas tablas ya existen en la base de datos: {e}") + else: + raise + + +async def init_async_db(): + """Inicializa las tablas de la base de datos core (async).""" + async with async_core_engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) diff --git a/backend/core/email.py b/backend/core/email.py new file mode 100644 index 0000000..79b4649 --- /dev/null +++ b/backend/core/email.py @@ -0,0 +1,118 @@ +""" +Email service for sending reports via SMTP. +""" +import aiosmtplib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.mime.base import MIMEBase +from email import encoders +from typing import List +import logging +from datetime import datetime + +from core.config import settings + +logger = logging.getLogger(__name__) + + +class EmailService: + """Service for sending emails with attachments.""" + + @staticmethod + async def send_report_email( + recipient_email: str, + subject: str, + body_text: str, + csv_content: str, + filename: str + ) -> bool: + """ + Send a report email with CSV attachment. + + Args: + recipient_email: Email address of recipient + subject: Email subject line + body_text: Plain text email body + csv_content: CSV file content as string + filename: Name for the CSV attachment + + Returns: + bool: True if email sent successfully, False otherwise + """ + try: + # Create message + msg = MIMEMultipart() + msg['From'] = f"{settings.SMTP_FROM_NAME} <{settings.SMTP_USER}>" + msg['To'] = recipient_email + msg['Subject'] = subject + + # Email body + html_body = f""" + + +
+

+ Reporte de Facturas +

+

{body_text}

+

+ El reporte se encuentra adjunto en formato CSV. +

+
+

+ Este es un correo generado automáticamente. Por favor no responder. +

+

+ Generado el {datetime.now().strftime('%d/%m/%Y a las %H:%M')} +

+
+ + + """ + msg.attach(MIMEText(html_body, 'html')) + + # CSV attachment with UTF-8 BOM for Excel compatibility + attachment = MIMEBase('text', 'csv') + csv_bytes = b'\xef\xbb\xbf' + csv_content.encode('utf-8') + attachment.set_payload(csv_bytes) + encoders.encode_base64(attachment) + attachment.add_header( + 'Content-Disposition', + f'attachment; filename="{filename}"' + ) + msg.attach(attachment) + + # Create SSL context that ignores certificate errors + import ssl + context = ssl.create_default_context() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + + # Send email + if settings.SMTP_PORT == 465: + # Port 465 uses implicit SSL + async with aiosmtplib.SMTP( + hostname=settings.SMTP_HOST, + port=settings.SMTP_PORT, + use_tls=True, # Implicit SSL + tls_context=context + ) as smtp: + await smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD) + await smtp.send_message(msg) + else: + # Port 587 uses STARTTLS + async with aiosmtplib.SMTP( + hostname=settings.SMTP_HOST, + port=settings.SMTP_PORT, + tls_context=context + ) as smtp: + await smtp.starttls(tls_context=context) + await smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD) + await smtp.send_message(msg) + + logger.info(f"Email sent successfully to {recipient_email}") + return True + + except Exception as e: + logger.error(f"Failed to send email to {recipient_email}: {str(e)}") + return False diff --git a/backend/core/error_handlers.py b/backend/core/error_handlers.py new file mode 100644 index 0000000..6d6e395 --- /dev/null +++ b/backend/core/error_handlers.py @@ -0,0 +1,357 @@ +""" +Manejadores globales de excepciones para FastAPI +""" + +import logging +from typing import Any, Dict + +from fastapi import Request, status, HTTPException +from fastapi.responses import JSONResponse +from fastapi.encoders import jsonable_encoder +from fastapi.encoders import jsonable_encoder +from fastapi.exceptions import RequestValidationError +from pydantic import ValidationError +from sqlalchemy.exc import IntegrityError, SQLAlchemyError + +from .config import settings +from .exceptions import BaseAPIException + +logger = logging.getLogger(__name__) + + +def _cors_headers(request: Request) -> Dict[str, str]: + """CORS headers for error responses so browser does not block on 4xx/5xx.""" + origin = request.headers.get("origin") + if not origin or origin not in settings.cors_origins_list: + return {} + return { + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Credentials": "true", + } + + +async def base_exception_handler( + request: Request, + exc: BaseAPIException, +) -> JSONResponse: + """ + Manejador para todas las excepciones personalizadas de la API + """ + logger.warning( + f"API Exception: {exc.error_code} - {exc.message}", + extra={ + "path": request.url.path, + "method": request.method, + "status_code": exc.status_code, + }, + ) + + # Log detailed errors if they exist + if hasattr(exc, "errors") and exc.errors: + logger.warning(f"Validation errors details: {exc.errors}") + + response = JSONResponse( + status_code=exc.status_code, + content=jsonable_encoder(exc.to_dict()), + ) + for k, v in _cors_headers(request).items(): + response.headers[k] = v + return response + + +# Mapa de campos técnicos a nombres legibles en español +_FIELD_LABELS: Dict[str, str] = { + "broker_key": "Clave del Agente", + "license": "Patente", + "tax_id": "RFC", + "personal_id": "CURP", + "email": "Correo Electrónico", + "phone": "Teléfono", + "fax": "Fax", + "contact": "Nombre de Contacto", + "name": "Nombre / Razón Social", + "address": "Dirección", + "postal_code": "Código Postal", + "city": "Ciudad", + "state": "Estado", + "country": "País", + # Partes (A76) + "unit_cost": "Costo Unitario", + "unit_weight": "Peso Unitario", + "sector": "Sector", + "fraction_type": "Tipo de tarifa", +} + +_FIELD_PATTERN_MESSAGES: Dict[str, str] = { + "broker_key": "La Clave del Agente solo puede contener letras y números (máx. 5 caracteres).", + "license": "La Patente debe ser un número entre 1 y 9999 (no puede ser 0 ni contener letras).", + "tax_id": "El RFC no tiene el formato correcto. Ejemplo válido: XAXX010101000.", + "personal_id": "La CURP no tiene el formato correcto. Debe tener 18 caracteres alfanuméricos.", + "email": "El correo electrónico no tiene un formato válido. Ejemplo: usuario@dominio.com.", + "phone": "El teléfono solo puede contener dígitos, espacios y los símbolos: +, -, (, ).", + "contact": "El nombre de contacto contiene caracteres no permitidos. Use solo letras, números y puntuación básica.", + # Partes (A76) + "sector": "El Sector solo puede contener números, máximo 8 dígitos (sin espacios ni caracteres especiales).", +} + + +def _friendly_message(field_key: str, error_type: str) -> str: + """Devuelve un mensaje de error legible en español según el campo y tipo de error.""" + if error_type in ("greater_than_equal",): + if field_key in ("unit_cost", "unit_weight"): + return f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' no puede ser negativo." + return f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' debe ser mayor o igual a 0." + if error_type in ("string_pattern_mismatch", "value_error"): + return _FIELD_PATTERN_MESSAGES.get( + field_key, + f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' contiene un valor con formato inválido.", + ) + if error_type in ("decimal_parsing", "decimal_type", "float_parsing", "float_type", "int_parsing", "int_type"): + return ( + f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' debe ser numérico. " + "Si no aplica, déjelo vacío." + ) + if error_type in ("literal_error",): + if field_key == "fraction_type": + return "El campo 'Tipo de tarifa' es inválido. Seleccione una opción predefinida." + return f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' contiene una opción inválida." + if error_type == "string_too_long": + return f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' excede la longitud máxima permitida." + if error_type == "string_too_short": + return f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' es demasiado corto." + if error_type in ("missing", "value_error.missing"): + return f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' es obligatorio." + return f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' contiene un valor inválido." + + +async def validation_exception_handler( + request: Request, + exc: RequestValidationError, +) -> JSONResponse: + """ + Manejador para errores de validación de Pydantic/FastAPI. + Devuelve mensajes legibles en español. + """ + errors = [] + for error in exc.errors(): + loc_parts = [str(loc) for loc in error["loc"] if loc != "body"] + field = ".".join(loc_parts) + field_key = loc_parts[-1] if loc_parts else "" + + errors.append( + { + "field": field, + "message": _friendly_message(field_key, error["type"]), + "type": error["type"], + } + ) + + print(f"DEBUG REQUEST VALIDATION ERRORS: {errors}") + logger.warning( + f"Validation Error en {request.url.path}", + extra={"errors": errors}, + ) + + summary = ( + errors[0]["message"] + if len(errors) == 1 + else f"Hay {len(errors)} errores de validación: " + " | ".join(e["message"] for e in errors) + ) + + response = JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + content={ + "error": "VALIDATION_ERROR", + "message": summary, + "status_code": status.HTTP_422_UNPROCESSABLE_CONTENT, + "errors": errors, + }, + ) + for k, v in _cors_headers(request).items(): + response.headers[k] = v + return response + + +import traceback + +async def inner_validation_exception_handler( + request: Request, + exc: ValidationError, +) -> JSONResponse: + """ + Manejador para errores de validación de Pydantic lanzados internamente (como en tenant_crud_routes). + """ + traceback.print_exc() + errors = [] + for error in exc.errors(): + loc_parts = [str(loc) for loc in error["loc"] if loc != "body"] + field = ".".join(loc_parts) + field_key = loc_parts[-1] if loc_parts else "" + + errors.append( + { + "field": field, + "message": _friendly_message(field_key, error["type"]), + "type": error["type"], + } + ) + + print(f"DEBUG VALIDATION ERRORS: {errors}") + logger.warning( + f"Inner Validation Error en {request.url.path}", + extra={"errors": errors}, + ) + + summary = ( + errors[0]["message"] + if len(errors) == 1 + else f"Hay {len(errors)} errores de validación: " + " | ".join(e["message"] for e in errors) + ) + + response = JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + content={ + "error": "VALIDATION_ERROR", + "message": summary, + "status_code": status.HTTP_422_UNPROCESSABLE_CONTENT, + "errors": errors, + }, + ) + for k, v in _cors_headers(request).items(): + response.headers[k] = v + return response + + +async def integrity_error_handler( + request: Request, + exc: IntegrityError, +) -> JSONResponse: + """ + Manejador para errores de integridad de la base de datos + """ + logger.error( + f"Database Integrity Error: {str(exc.orig)}", + extra={ + "path": request.url.path, + "method": request.method, + }, + ) + + orig_msg = str(exc.orig).lower() + + # Check for unique/duplicate key violations (English and Spanish) + if any(kw in orig_msg for kw in ["unique constraint", "duplicate key", "duplicada", "unicidad", "ya existe"]): + error_message = "El registro ya existe. Verifica los campos únicos (Año, Aduana, Patente, Número, etc.)." + # Check for foreign key violations (English and Spanish) + elif any(kw in orig_msg for kw in ["foreign key", "foránea", "referencia"]): + error_message = "Referencia inválida a otro registro. Verifica las categorías y catálogos seleccionados." + # Check for not null violations (English and Spanish) + elif any(kw in orig_msg for kw in ["not null", "no nulo", "valor nulo"]): + error_message = "Falta un campo requerido. Asegúrate de llenar todos los datos obligatorios." + else: + error_message = "Error de integridad en la base de datos" + + response = JSONResponse( + status_code=status.HTTP_409_CONFLICT, + content={ + "error": "DATABASE_INTEGRITY_ERROR", + "message": error_message, + "status_code": status.HTTP_409_CONFLICT, + }, + ) + for k, v in _cors_headers(request).items(): + response.headers[k] = v + return response + + +async def sqlalchemy_error_handler( + request: Request, + exc: SQLAlchemyError, +) -> JSONResponse: + """ + Manejador para errores generales de SQLAlchemy + """ + logger.error( + f"Database Error: {str(exc)}", + extra={ + "path": request.url.path, + "method": request.method, + }, + exc_info=True, + ) + + response = JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={ + "error": "DATABASE_ERROR", + "message": f"Error en la operación de base de datos: {str(exc)}", + "status_code": status.HTTP_500_INTERNAL_SERVER_ERROR, + }, + ) + for k, v in _cors_headers(request).items(): + response.headers[k] = v + return response + + +async def http_exception_handler( + request: Request, + exc: HTTPException, +) -> JSONResponse: + """ + Manejador para HTTPException de FastAPI + """ + return JSONResponse( + status_code=exc.status_code, + content={ + "error": "HTTP_ERROR", + "message": exc.detail, + "status_code": exc.status_code, + }, + ) + + +async def general_exception_handler( + request: Request, + exc: Exception, +) -> JSONResponse: + """ + Manejador para excepciones no capturadas + """ + logger.error( + f"Unhandled Exception: {str(exc)}", + extra={ + "path": request.url.path, + "method": request.method, + }, + exc_info=True, + ) + + response = JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={ + "error": "INTERNAL_SERVER_ERROR", + "message": f"Error interno del servidor: {str(exc)}", + "status_code": status.HTTP_500_INTERNAL_SERVER_ERROR, + }, + ) + for k, v in _cors_headers(request).items(): + response.headers[k] = v + + return response + + +def register_exception_handlers(app) -> None: + """ + Registra todos los manejadores de excepciones en la aplicación FastAPI + + Args: + app: Instancia de FastAPI + """ + app.add_exception_handler(BaseAPIException, base_exception_handler) + app.add_exception_handler(HTTPException, http_exception_handler) + app.add_exception_handler(RequestValidationError, validation_exception_handler) + app.add_exception_handler(ValidationError, inner_validation_exception_handler) + app.add_exception_handler(IntegrityError, integrity_error_handler) + app.add_exception_handler(SQLAlchemyError, sqlalchemy_error_handler) + app.add_exception_handler(Exception, general_exception_handler) + diff --git a/backend/core/exceptions.py b/backend/core/exceptions.py new file mode 100644 index 0000000..6c73e4f --- /dev/null +++ b/backend/core/exceptions.py @@ -0,0 +1,300 @@ +""" +Sistema centralizado de excepciones personalizadas +""" + +from typing import Optional, List, Dict, Any +from fastapi import status + + +class BaseAPIException(Exception): + """Excepción base para todas las excepciones de la API""" + + def __init__( + self, + message: str, + status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR, + errors: Optional[List[Dict[str, Any]]] = None, + error_code: Optional[str] = None, + ): + self.message = message + self.status_code = status_code + self.errors = errors or [] + self.error_code = error_code or self.__class__.__name__ + super().__init__(self.message) + + def to_dict(self) -> Dict[str, Any]: + """Convierte la excepción a un diccionario para respuesta JSON""" + response = { + "error": self.error_code, + "message": self.message, + "status_code": self.status_code, + } + if self.errors: + response["errors"] = self.errors + return response + + +class ValidationException(BaseAPIException): + """Excepción para errores de validación""" + + def __init__( + self, + message: str = "Error de validación", + errors: Optional[List[Dict[str, Any]]] = None, + ): + super().__init__( + message=message, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + errors=errors, + error_code="VALIDATION_ERROR", + ) + + +class DuplicateResourceException(BaseAPIException): + """Excepción cuando se intenta crear un recurso duplicado""" + + def __init__( + self, + resource: str, + identifier: str, + message: Optional[str] = None, + ): + self.resource = resource + self.identifier = identifier + final_message = ( + message or f"{resource} con identificador '{identifier}' ya existe" + ) + super().__init__( + message=final_message, + status_code=status.HTTP_409_CONFLICT, + error_code="DUPLICATE_RESOURCE", + ) + + +class ResourceNotFoundException(BaseAPIException): + """Excepción cuando no se encuentra un recurso""" + + def __init__( + self, + resource: str, + identifier: str, + message: Optional[str] = None, + ): + self.resource = resource + self.identifier = identifier + final_message = ( + message or f"{resource} con identificador '{identifier}' no encontrado" + ) + super().__init__( + message=final_message, + status_code=status.HTTP_404_NOT_FOUND, + error_code="RESOURCE_NOT_FOUND", + ) + + +class UnauthorizedException(BaseAPIException): + """Excepción para errores de autenticación""" + + def __init__(self, message: str = "No autorizado"): + super().__init__( + message=message, + status_code=status.HTTP_401_UNAUTHORIZED, + error_code="UNAUTHORIZED", + ) + + +class ForbiddenException(BaseAPIException): + """Excepción para errores de permisos""" + + def __init__(self, message: str = "Acceso prohibido"): + super().__init__( + message=message, + status_code=status.HTTP_403_FORBIDDEN, + error_code="FORBIDDEN", + ) + + +class BusinessRuleException(BaseAPIException): + """Excepción para errores de reglas de negocio""" + + def __init__( + self, + message: str, + errors: Optional[List[Dict[str, Any]]] = None, + ): + super().__init__( + message=message, + status_code=status.HTTP_400_BAD_REQUEST, + errors=errors, + error_code="BUSINESS_RULE_ERROR", + ) + + +class DatabaseException(BaseAPIException): + """Excepción para errores de base de datos""" + + def __init__(self, message: str = "Error en la base de datos"): + super().__init__( + message=message, + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + error_code="DATABASE_ERROR", + ) + + +class ErrorCollector: + """ + Colector de errores para acumular múltiples errores de validación + antes de lanzar una excepción + + Uso: + collector = ErrorCollector() + + if not valid_email: + collector.add_error("email", "Email inválido", "INVALID_EMAIL") + + if not valid_phone: + collector.add_error("phone", "Teléfono inválido", "INVALID_PHONE") + + collector.raise_if_errors() # Lanza ValidationException si hay errores + """ + + def __init__(self): + self._errors: List[Dict[str, Any]] = [] + + def add_error( + self, + field: str, + message: str, + solution: Optional[List[str]], + code: Optional[str] = None, + value: Optional[Any] = None, + ) -> "ErrorCollector": + """ + Agrega un error al colector + + Args: + field: Campo donde ocurrió el error (ej: "invoice_number", "email") + message: Mensaje descriptivo del error + code: Código opcional del error (ej: "REQUIRED", "INVALID_FORMAT") + value: Valor que causó el error (opcional) + + Returns: + Self para permitir encadenamiento + """ + error = { + "field": field, + "message": message, + } + if solution: + error["solution"] = solution + if code: + error["code"] = code + if value is not None: + error["value"] = value + + self._errors.append(error) + return self + + def add_field_error( + self, + field: str, + message: str, + code: str = "INVALID", + ) -> "ErrorCollector": + """Atajo para agregar error de campo""" + return self.add_error(field, message, solution=None, code=code) + + def add_required_error(self, field: str) -> "ErrorCollector": + """Atajo para agregar error de campo requerido""" + return self.add_error( + field, f"El campo '{field}' es requerido", solution=None, code="REQUIRED" + ) + + def add_duplicate_error( + self, + field: str, + value: Any, + message: Optional[str] = None, + ) -> "ErrorCollector": + """Atajo para agregar error de duplicado""" + final_message = ( + message or f"El valor '{value}' ya existe para el campo '{field}'" + ) + return self.add_error( + field, final_message, solution=None, code="DUPLICATE", value=value + ) + + def add_invalid_format_error( + self, + field: str, + expected_format: str, + ) -> "ErrorCollector": + """Atajo para agregar error de formato inválido""" + return self.add_error( + field, + f"Formato inválido. Se esperaba: {expected_format}", + solution=None, + code="INVALID_FORMAT", + ) + + def add_range_error( + self, + field: str, + min_value: Optional[Any] = None, + max_value: Optional[Any] = None, + ) -> "ErrorCollector": + """Atajo para agregar error de rango""" + if min_value is not None and max_value is not None: + message = f"El valor debe estar entre {min_value} y {max_value}" + elif min_value is not None: + message = f"El valor debe ser mayor o igual a {min_value}" + elif max_value is not None: + message = f"El valor debe ser menor o igual a {max_value}" + else: + message = "Valor fuera de rango" + + return self.add_error(field, message, solution=None, code="OUT_OF_RANGE") + + def has_errors(self) -> bool: + """Verifica si hay errores acumulados""" + return len(self._errors) > 0 + + def get_errors(self) -> List[Dict[str, Any]]: + """Obtiene la lista de errores""" + return self._errors.copy() + + def get_error_count(self) -> int: + """Obtiene el número de errores""" + return len(self._errors) + + def clear(self) -> "ErrorCollector": + """Limpia todos los errores""" + self._errors.clear() + return self + + def raise_if_errors( + self, + message: str = "Se encontraron errores de validación", + ) -> None: + """ + Lanza ValidationException si hay errores acumulados + + Args: + message: Mensaje principal de la excepción + + Raises: + ValidationException: Si hay errores acumulados + """ + if self.has_errors(): + raise ValidationException(message=message, errors=self._errors) + + def __bool__(self) -> bool: + """Permite usar el colector en contextos booleanos""" + return self.has_errors() + + def __len__(self) -> int: + """Permite usar len() en el colector""" + return self.get_error_count() + + def __repr__(self) -> str: + return f"ErrorCollector(errors={self.get_error_count()})" diff --git a/backend/core/middleware.py b/backend/core/middleware.py new file mode 100644 index 0000000..89d7001 --- /dev/null +++ b/backend/core/middleware.py @@ -0,0 +1,332 @@ +import logging +import time +import httpx +from datetime import datetime, timezone +from typing import Callable, Optional +from fastapi import Request, Response +from fastapi.responses import JSONResponse +from starlette.middleware.base import BaseHTTPMiddleware + +from .config import settings +from .security import get_tenant_from_token, verify_token, get_active_system + +logger = logging.getLogger(__name__) + + +def _normalize_text(value: str | None) -> str: + if not value: + return "" + return str(value).strip().lower() + + +def _is_token_issue_message(*values: str | None) -> bool: + text = " ".join(_normalize_text(v) for v in values if v) + if not text: + return False + + token_markers = ["token", "jwt", "bearer", "access"] + invalid_markers = [ + "invalido", "inválido", "invalid", "not valid", "malformed", "signature", "unauthorized" + ] + expired_markers = ["expirado", "expirada", "expired", "has expired", "caducado", "vencido"] + + has_token_context = any(marker in text for marker in token_markers) + has_invalid_marker = any(marker in text for marker in invalid_markers) + has_expired_marker = any(marker in text for marker in expired_markers) + + return (has_expired_marker and has_token_context) or (has_token_context and has_invalid_marker) + + +def _extract_company_id(request: Request) -> Optional[int]: + """Obtiene ``company_id`` activa desde header ``X-Company-Id`` o cookie. + + El frontend guarda la compañía activa en la cookie ``active_company_id`` + (ver ``frontend/src/lib/stores/company.svelte.ts``). El header es la + ruta explícita para clientes no-browser. + """ + header_value = request.headers.get("X-Company-Id") + raw = header_value or request.cookies.get("active_company_id") + if not raw: + return None + try: + return int(raw) + except (TypeError, ValueError): + return None + + +class TenantMiddleware(BaseHTTPMiddleware): + """ + Middleware original para extraer tenant_id y user_info del token. + """ + async def dispatch(self, request: Request, call_next: Callable): + doc_prefixes = ["/api/redoc", "/api/openapi.json"] + public_prefixes = [ + "/api/v1/auth", + "/api/v1/status", + "/api/health", + "/api/", + "/uploads", + "/api/v1/core/help-center", + "/api/v1/core/users/avatar", + ] + + path = request.url.path + + if any(path == prefix or path.startswith(prefix + "/") for prefix in doc_prefixes): + return await call_next(request) + + if any(path == prefix or (prefix != "/" and path.startswith(prefix)) for prefix in public_prefixes): + return await call_next(request) + + auth_header = request.headers.get("Authorization") + if not auth_header or not auth_header.startswith("Bearer "): + return JSONResponse( + status_code=401, + content={ + "error": "HTTP_ERROR", + "message": "Missing or invalid authorization header", + "status_code": 401, + } + ) + + token = auth_header.split(" ")[1] + try: + user_info = await verify_token(token) + tenant_id = get_tenant_from_token(user_info) + + request.state.tenant_id = tenant_id + request.state.user_info = user_info + request.state.company_id = _extract_company_id(request) + request.state.active_system = get_active_system(request) + except Exception as e: + logger.error(f"❌ Tenant validation error: {str(e)}") + return JSONResponse( + status_code=401, + content={ + "error": "HTTP_ERROR", + "message": "Invalid authentication", + "status_code": 401, + } + ) + + return await call_next(request) + + +class LicenseValidationMiddleware(BaseHTTPMiddleware): + """ + Middleware que valida la licencia contra el Hub de Aduanasoft. + El Hub siempre es requerido — tanto en SaaS como en self-hosted. + Fail-closed: si el Hub no responde o la licencia es inválida, se bloquea el acceso. + """ + async def dispatch(self, request: Request, call_next: Callable): + # En modo local (DEV_LOCAL_AUTH) no hay Hub — saltar validación de licencia. + if settings.DEV_LOCAL_AUTH: + return await call_next(request) + + exempt_paths = [ + "/api/docs", "/api/redoc", "/openapi.json", + "/api/v1/auth", "/api/v1/status", "/api/health", + "/api/v1/core/help-center", + "/api/v1/core/users/avatar", + ] + + is_exempt = any( + request.url.path == path or (path != "/" and request.url.path.startswith(path)) + for path in exempt_paths + ) + + if is_exempt: + return await call_next(request) + + auth_header = request.headers.get("Authorization") + if not auth_header or not auth_header.startswith("Bearer "): + # Permitimos pasar para que TenantMiddleware maneje el 401 + return await call_next(request) + + token = auth_header.split(" ")[1] + + tenant_override = request.headers.get("X-Tenant-Override") + if not tenant_override: + # Fallback para flujos SSO cuando el override no viaja en header. + tenant_override = request.cookies.get("sso_tenant_id") or request.cookies.get("sso_tenant_pub") + + # TenantMiddleware (corre antes) ya resolvió el token y dejó tenant en user_info. + # Sin esto, Swagger/curl sin cookies SSO llaman verify-license sin contexto y el Hub + # puede devolver 401 aunque /auth/me con el mismo Bearer responda 200. + if not tenant_override: + user_info = getattr(request.state, "user_info", None) + if isinstance(user_info, dict): + tid = user_info.get("tenant_id") + if tid is not None and str(tid).strip() != "": + tenant_override = str(tid) + + hub_headers = {"Authorization": f"Bearer {token}"} + if tenant_override: + hub_headers["X-Tenant-Override"] = str(tenant_override) + logger.info("[license] tenant override propagated to Hub: %s", tenant_override) + + # Solo la petición HTTP al Hub va en try: los errores de rutas (p. ej. ContextVar RLS) + # deben propagarse y no etiquetarse como fallo de licencia. + try: + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.get( + f"{settings.HUB_URL}api/v1/auth/verify-license", + headers=hub_headers + ) + except (httpx.ConnectError, httpx.TimeoutException) as e: + logger.critical(f"❌ CRITICAL: Hub unreachable: {str(e)}") + return JSONResponse( + status_code=503, + content={ + "error": "HUB_OFFLINE", + "message": "Servicio de licencias fuera de línea. Acceso denegado.", + "status_code": 503, + } + ) + except Exception as e: + logger.exception("Hub verify-license request failed: %s", e) + return JSONResponse( + status_code=500, + content={ + "error": "VALIDATION_ERROR", + "message": "Error interno al contactar el servicio de licencias.", + "status_code": 500, + } + ) + + if response.status_code == 404: + # Endpoint no existe en este Hub — dejar pasar + return await call_next(request) + + if response.status_code == 200: + try: + data = response.json() + except Exception as e: + logger.error(f"Hub verify-license JSON parse failed: {str(e)}") + return JSONResponse( + status_code=503, + content={ + "error": "HUB_ERROR", + "message": "Respuesta inválida del servidor de licencias.", + "status_code": 503, + } + ) + + # Escenario 1: sin licencia asignada o licencia inactiva + if not data.get("valid", False): + message = data.get("message", "Sin licencia asignada para este tenant") + detail = data.get("detail") + reason = data.get("reason") + # Si el Hub reporta token inválido/expirado, devolver 401 para que + # el frontend dispare el auto-refresh (solo se activa con 401/403, no 402). + if _is_token_issue_message(message, detail, reason): + logger.warning( + "[license] token expirado/invalido detectado por verify-license; devolviendo 401 para silent refresh | message=%s detail=%s reason=%s", + message, + detail, + reason, + ) + return JSONResponse( + status_code=401, + content={ + "error": "TOKEN_EXPIRED", + "message": message, + "status_code": 401, + } + ) + + logger.warning( + "[license] licencia invalida para tenant=%s | message=%s", + data.get("tenant_slug"), + message, + ) + return JSONResponse( + status_code=402, + content={ + "error": "LICENSE_ERROR", + "message": message, + "status_code": 402, + } + ) + + # Escenario 2: licencia vencida (verificación local de expires_at) + expires_at_str = data.get("expires_at") + if expires_at_str: + try: + expires_at = datetime.fromisoformat(expires_at_str.replace("Z", "+00:00")) + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + if expires_at < datetime.now(timezone.utc): + logger.warning( + "[license] licencia expirada para tenant=%s | expires_at=%s", + data.get("tenant_slug"), + expires_at_str, + ) + return JSONResponse( + status_code=402, + content={ + "error": "LICENSE_EXPIRED", + "message": f"La licencia venció el {expires_at.strftime('%d/%m/%Y')}. Renueva tu suscripción.", + "status_code": 402, + } + ) + except (ValueError, TypeError): + pass # Si no se puede parsear, dejamos pasar — el Hub es la fuente de verdad + + request.state.license_info = data + return await call_next(request) + + if response.status_code == 401: + logger.warning("[license] Hub verify-license devolvio 401 (token invalido/expirado)") + return JSONResponse( + status_code=401, + content={ + "error": "TOKEN_EXPIRED", + "message": "Token inválido o expirado.", + "status_code": 401, + } + ) + + if response.status_code == 403: + return JSONResponse( + status_code=403, + content={ + "error": "FORBIDDEN", + "message": "El Tenant no tiene permisos en el Hub central.", + "status_code": 403, + } + ) + + logger.error(f"Hub error status: {response.status_code}") + return JSONResponse( + status_code=503, + content={ + "error": "HUB_ERROR", + "message": "Error en el servidor de licencias.", + "status_code": 503, + } + ) + + +class RequestLoggingMiddleware(BaseHTTPMiddleware): + """ + Middleware original para logging de performance. + """ + async def dispatch(self, request: Request, call_next: Callable): + start_time = time.time() + excluded_paths = ["/api/docs", "/api/redoc", "/openapi.json", "/api/v1/status", "/api/health"] + + if any(request.url.path == path or request.url.path.startswith(path + "/") for path in excluded_paths): + return await call_next(request) + + logger.info(f"Request: {request.method} {request.url.path}") + response = await call_next(request) + process_time = time.time() - start_time + + logger.info( + f"Response: {request.method} {request.url.path} " + f"Status: {response.status_code} " + f"Duration: {process_time:.3f}s" + ) + response.headers["X-Process-Time"] = str(process_time) + return response \ No newline at end of file diff --git a/backend/core/paths.py b/backend/core/paths.py new file mode 100644 index 0000000..05e33b3 --- /dev/null +++ b/backend/core/paths.py @@ -0,0 +1,15 @@ +""" +Rutas base y resolución de paths para layouts (importación CSV, temp, errors). +""" +from pathlib import Path + +# Raíz del backend (directorio que contiene api/, core/, etc.) +BASE_DIR = Path(__file__).resolve().parent.parent + + +def layout_path(*parts: str) -> str: + """Construye una ruta absoluta bajo backend/layouts/.""" + p = BASE_DIR / "layouts" + for part in parts: + p = p / part + return str(p) diff --git a/backend/core/s3_keys.py b/backend/core/s3_keys.py new file mode 100644 index 0000000..6959336 --- /dev/null +++ b/backend/core/s3_keys.py @@ -0,0 +1,427 @@ +""" +Convención de claves S3/MinIO para objetos persistidos. + +Todas las cargas que usen ``put_object_bytes`` deben obtener la clave mediante +funciones de este módulo (no construir ``tenants/...`` a mano en las rutas HTTP). + +Árbol canónico +-------------- + +**Multi-tenant** (datos de clientes), siempre bajo ``tenants/{tenant_id}/``: + +- ``tenants/{tid}/users/{keycloak_sub}/`` + Perfil de usuario (avatar). Ver ``tenant_user_prefix``, ``user_avatar_key``. + +- ``tenants/{tid}/companies/{company_id}/`` + Recursos ligados a una empresa: + + - ``.../doda/{doda_id}/report/doda_report.pdf`` — reporte DODA en PDF. ``doda_report_pdf_key``. + - ``.../branding/{filename}`` — logo. ``company_logo_key``. + - ``.../certificates/{tipo}_{timestamp}.{cer|key}`` — CER/KEY FIEL, CFDI, cancelación. + ``company_certificate_key``. + - ``.../imports/csv/{job_type}/{job_id}.csv`` — CSV de layouts (import jobs). + ``csv_import_key`` (usado por ``storage_s3.s3_key_for_csv_import``). + - ``.../customs_brokers/{broker_id}/certificates/`` — CER del VU (``.cer``). + ``customs_broker_vu_certificate_key``. + - ``.../customs_brokers/{broker_id}/keys/`` — llave privada VU (``.key``). + ``customs_broker_vu_private_key_key``. + - ``.../customs_brokers/{broker_id}/cove/`` — archivos COVE (xml, zip, etc.). + ``customs_broker_vu_cove_key``. + +**Sistema global** (no por tenant): + +- ``system/help/{carpeta opcional}/{archivo}`` — biblioteca de ayuda (imágenes, PDFs, vídeos). + ``help_asset_key``, ``global_system_prefix``. Lectura HTTP mapea a este prefijo. + +**Legado / migración**: + +- ``imports/csv/{job_type}/{job_id}.csv`` — sin tenant/company. Solo ``legacy_csv_import_key`` + (cleanup o compatibilidad). + +Constantes públicas +------------------- + +``SYSTEM_HELP_PREFIX`` — prefijo literal ``system/help/`` para lecturas y utilidades +que no pasan por ``help_asset_key``. +""" +import re +from typing import Union + +# Segmentos permitidos en claves (evita path traversal) +_SAFE_SEGMENT = re.compile(r"^[a-zA-Z0-9._\-]+$") + +# Prefijo fijo para objetos de Help Center (debe coincidir con help_asset_key / GET /files/) +SYSTEM_HELP_PREFIX = "system/help/" + + +def _segment(value: Union[int, str], label: str) -> str: + s = str(value).strip() + if not s or "/" in s or ".." in s: + raise ValueError(f"invalid {label} segment") + if not _SAFE_SEGMENT.match(s): + raise ValueError(f"invalid {label} characters") + return s + + +def tenant_company_prefix(tenant_id: Union[int, str], company_id: int) -> str: + """Prefijo `tenants/{tid}/companies/{cid}/` (termina en /).""" + tid = _segment(tenant_id, "tenant_id") + cid = _segment(company_id, "company_id") + return f"tenants/{tid}/companies/{cid}/" + + +def tenant_user_prefix(tenant_id: Union[int, str], keycloak_user_id: str) -> str: + """Prefijo `tenants/{tid}/users/{keycloak_sub}/` (avatar de perfil, sin company).""" + tid = _segment(tenant_id, "tenant_id") + kid = _segment(keycloak_user_id, "keycloak_user_id") + return f"tenants/{tid}/users/{kid}/" + + +def user_avatar_key( + tenant_id: Union[int, str], + keycloak_user_id: str, + ext: str, +) -> str: + ext = ext.lower() if ext.startswith(".") else f".{ext}" + allowed = (".jpg", ".jpeg", ".png", ".gif", ".webp") + if ext not in allowed: + raise ValueError("invalid avatar extension") + return f"{tenant_user_prefix(tenant_id, keycloak_user_id)}avatar{ext}" + + +def public_user_avatar_api_path(tenant_id: int, keycloak_user_id: str) -> str: + """Ruta GET pública para servir la imagen (sin host).""" + return f"/api/v1/core/users/avatar/{tenant_id}/{keycloak_user_id}" + + +def global_system_prefix(subpath: str = "help") -> str: + """Prefijo bajo `system/` para contenido global (p. ej. help). Termina en /.""" + sub = subpath.strip().strip("/") + if not sub: + return SYSTEM_HELP_PREFIX + parts = sub.split("/") + for p in parts: + _segment(p, "system_subpath") + return f"system/{sub}/" + + +def customs_broker_vu_prefix( + tenant_id: Union[int, str], + company_id: int, + broker_id: int, +) -> str: + """ + Prefijo para el bloque VU del agente aduanal. + + Forma: ``tenants/{tid}/companies/{cid}/customs_brokers/{broker_id}/`` + """ + bid = _segment(str(broker_id), "broker_id") + return f"{tenant_company_prefix(tenant_id, company_id)}customs_brokers/{bid}/" + + +def customs_broker_vu_certificate_key( + tenant_id: Union[int, str], + company_id: int, + broker_id: int, + timestamp: str, + file_ext: str, +) -> str: + """CER del VU bajo ``.../customs_brokers/{id}/certificates/vu_cer_{timestamp}.cer``.""" + ts = _segment(timestamp, "timestamp") + ext = file_ext.lower() if str(file_ext).startswith(".") else f".{file_ext}" + if ext != ".cer": + raise ValueError("VU certificate must be .cer") + base = f"vu_cer_{ts}{ext}" + return f"{customs_broker_vu_prefix(tenant_id, company_id, broker_id)}certificates/{base}" + + +def customs_broker_vu_private_key_key( + tenant_id: Union[int, str], + company_id: int, + broker_id: int, + timestamp: str, + file_ext: str, +) -> str: + """Llave privada del VU bajo ``.../customs_brokers/{id}/keys/vu_key_{timestamp}.key``.""" + ts = _segment(timestamp, "timestamp") + ext = file_ext.lower() if str(file_ext).startswith(".") else f".{file_ext}" + if ext != ".key": + raise ValueError("VU private key must be .key") + base = f"vu_key_{ts}{ext}" + return f"{customs_broker_vu_prefix(tenant_id, company_id, broker_id)}keys/{base}" + + +def customs_broker_vu_cove_key( + tenant_id: Union[int, str], + company_id: int, + broker_id: int, + timestamp: str, + original_filename: str, +) -> str: + """ + Archivos COVE bajo ``.../customs_brokers/{id}/cove/cove_{timestamp}_{filename}``. + Extensiones típicas: .xml, .zip, .txt, .pdf, .json + """ + ts = _segment(timestamp, "timestamp") + fn = safe_filename(original_filename) + parts = fn.rsplit(".", 1) + if len(parts) < 2: + raise ValueError("COVE file must have an extension") + ext = "." + parts[1].lower() + allowed = (".xml", ".zip", ".txt", ".pdf", ".json") + if ext not in allowed: + raise ValueError(f"COVE extension not allowed: {ext}") + base = f"cove_{ts}_{fn}" + return f"{customs_broker_vu_prefix(tenant_id, company_id, broker_id)}cove/{base}" + + +def customs_broker_vu_doda_certificate_key( + tenant_id: Union[int, str], + company_id: int, + broker_id: int, + timestamp: str, + file_ext: str, +) -> str: + """CER DODA bajo ``.../customs_brokers/{id}/doda/certificates/doda_cer_{timestamp}.cer``.""" + ts = _segment(timestamp, "timestamp") + ext = file_ext.lower() if str(file_ext).startswith(".") else f".{file_ext}" + if ext != ".cer": + raise ValueError("DODA certificate must be .cer") + base = f"doda_cer_{ts}{ext}" + return f"{customs_broker_vu_prefix(tenant_id, company_id, broker_id)}doda/certificates/{base}" + + +def customs_broker_vu_doda_private_key_key( + tenant_id: Union[int, str], + company_id: int, + broker_id: int, + timestamp: str, + file_ext: str, +) -> str: + """Llave DODA bajo ``.../customs_brokers/{id}/doda/keys/doda_key_{timestamp}.key``.""" + ts = _segment(timestamp, "timestamp") + ext = file_ext.lower() if str(file_ext).startswith(".") else f".{file_ext}" + if ext != ".key": + raise ValueError("DODA private key must be .key") + base = f"doda_key_{ts}{ext}" + return f"{customs_broker_vu_prefix(tenant_id, company_id, broker_id)}doda/keys/{base}" + + +def customs_broker_vu_doda_cove_key( + tenant_id: Union[int, str], + company_id: int, + broker_id: int, + timestamp: str, + original_filename: str, +) -> str: + """ + Archivos DODA XML bajo ``.../customs_brokers/{id}/doda/cove/doda_cove_{timestamp}_{filename}``. + Extensiones permitidas: .xml, .zip, .txt, .pdf, .json + """ + ts = _segment(timestamp, "timestamp") + fn = safe_filename(original_filename) + parts = fn.rsplit(".", 1) + if len(parts) < 2: + raise ValueError("DODA file must have an extension") + ext = "." + parts[1].lower() + allowed = (".xml", ".zip", ".txt", ".pdf", ".json") + if ext not in allowed: + raise ValueError(f"DODA extension not allowed: {ext}") + base = f"doda_cove_{ts}_{fn}" + return f"{customs_broker_vu_prefix(tenant_id, company_id, broker_id)}doda/cove/{base}" + + +def job_type_segment(job_type: str) -> str: + if job_type == "" or job_type == "invoice": + return "invoice" + return job_type + + +def csv_import_key( + tenant_id: Union[int, str], + company_id: int, + job_type: str, + job_id: str, +) -> str: + _segment(job_id, "job_id") + return ( + f"{tenant_company_prefix(tenant_id, company_id)}" + f"imports/csv/{job_type_segment(job_type)}/{job_id}.csv" + ) + + +def legacy_csv_import_key(job_type: str, job_id: str) -> str: + """Clave antigua sin tenant/company (solo migración / cleanup).""" + _segment(job_id, "job_id") + return f"imports/csv/{job_type_segment(job_type)}/{job_id}.csv" + + +def safe_filename(filename: str) -> str: + """Nombre de archivo final sin separadores.""" + base = filename.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] + if not base or ".." in base: + raise ValueError("invalid filename") + return base + + +def company_logo_key( + tenant_id: Union[int, str], + company_id: int, + filename: str, +) -> str: + fn = safe_filename(filename) + return f"{tenant_company_prefix(tenant_id, company_id)}branding/{fn}" + + +def doda_report_pdf_key( + tenant_id: Union[int, str], + company_id: int, + doda_id: int, +) -> str: + """ + Reporte DODA en PDF bajo ``.../doda/{doda_id}/report/doda_report.pdf`` (clave estable). + """ + did = _segment(doda_id, "doda_id") + return f"{tenant_company_prefix(tenant_id, company_id)}doda/{did}/report/doda_report.pdf" + + +def company_certificate_key( + tenant_id: Union[int, str], + company_id: int, + certificate_type: str, + timestamp: str, + file_ext: str, +) -> str: + ct = _segment(certificate_type.replace(".", "_"), "certificate_type") + ts = _segment(timestamp, "timestamp") + ext = file_ext.lower() if file_ext.startswith(".") else f".{file_ext}" + if ext not in (".cer", ".key"): + raise ValueError("certificate file must be .cer or .key") + base = f"{ct}_{ts}{ext}" + return f"{tenant_company_prefix(tenant_id, company_id)}certificates/{base}" + + +def expediente_archivo_document_key( + tenant_id: Union[int, str], + company_id: int, + expediente_id: int, + timestamp: str, + original_filename: str, +) -> str: + """ + Archivo del expediente bajo ``.../expediente_archivos/{id}/documents/expediente_{timestamp}_{filename}``. + Extensiones permitidas: .pdf, .xml, .png, .jpg, .jpeg, .json, .txt, .zip + """ + ts = _segment(timestamp, "timestamp") + eid = _segment(expediente_id, "expediente_id") + fn = safe_filename(original_filename) + parts = fn.rsplit(".", 1) + if len(parts) < 2: + raise ValueError("expediente file must have an extension") + ext = "." + parts[1].lower() + allowed = (".pdf", ".xml", ".png", ".jpg", ".jpeg", ".json", ".txt", ".zip") + if ext not in allowed: + raise ValueError(f"expediente file extension not allowed: {ext}") + base = f"expediente_{ts}_{fn}" + return f"{tenant_company_prefix(tenant_id, company_id)}expediente_archivos/{eid}/documents/{base}" + + +def expediente_archivo_artifact_key( + tenant_id: Union[int, str], + company_id: int, + expediente_id: int, + artifact_type: str, + timestamp: str, +) -> str: + """ + Artefacto de digitalización bajo ``.../expediente_archivos/{id}/artifacts/{type}_{timestamp}.{ext}``. + artifact_type: acuse | envio_xml | respuesta_xml | consulta_envio_xml | consulta_respuesta_xml + """ + ts = _segment(timestamp, "timestamp") + eid = _segment(expediente_id, "expediente_id") + at = _segment(artifact_type, "artifact_type") + ext = ".pdf" if artifact_type == "acuse" else ".xml" + return f"{tenant_company_prefix(tenant_id, company_id)}expediente_archivos/{eid}/artifacts/{at}_{ts}{ext}" + + +def cove_xml_key( + tenant_id: Union[int, str], + company_id: int, + invoice_id: int, +) -> str: + """ + XML de COVE devuelto por Ventanilla Única, bajo + ``.../invoices/{invoice_id}/cove/cove.xml`` (clave estable por factura). + """ + iid = _segment(invoice_id, "invoice_id") + return f"{tenant_company_prefix(tenant_id, company_id)}invoices/{iid}/cove/cove.xml" + + +def cove_acuse_pdf_key( + tenant_id: Union[int, str], + company_id: int, + invoice_id: int, +) -> str: + """ + PDF de Acuse de COVE bajo + ``.../invoices/{invoice_id}/cove/acuse_cove.pdf`` (clave estable por factura). + """ + iid = _segment(invoice_id, "invoice_id") + return f"{tenant_company_prefix(tenant_id, company_id)}invoices/{iid}/cove/acuse_cove.pdf" + + +def help_asset_key(folder: str, new_filename: str) -> str: + """ + folder: '', 'pdfs', 'videos', 'assets' relativo a system/help/ + """ + folder = folder.strip().strip("/") + fn = safe_filename(new_filename) + if folder: + for p in folder.split("/"): + _segment(p, "help_folder") + return f"{global_system_prefix('help')}{folder}/{fn}" + return f"{global_system_prefix('help')}{fn}" + + +def help_s3_key_to_public_relative_path(key: str) -> str: + """Parte tras `system/help/` para el path del endpoint público.""" + if not key.startswith(SYSTEM_HELP_PREFIX): + raise ValueError("key is not under system/help/") + return key[len(SYSTEM_HELP_PREFIX) :] + + +def help_public_api_path(relative_under_help: str) -> str: + """URL de lectura pública bajo el router help-center (sin host).""" + rel = relative_under_help.lstrip("/") + return f"/api/v1/core/help-center/files/{rel}" + + +def signature_photo_key( + tenant_id: Union[int, str], + company_id: int, + signature_id: int, + timestamp: str, + file_ext: str, +) -> str: + """ + Foto de firma bajo ``.../signatures/{signature_id}/photo_{timestamp}.{ext}``. + Extensiones permitidas: .jpg, .jpeg, .png, .gif, .webp + """ + sid = _segment(signature_id, "signature_id") + ts = _segment(timestamp, "timestamp") + ext = file_ext.lower() if str(file_ext).startswith(".") else f".{file_ext}" + allowed = (".jpg", ".jpeg", ".png", ".gif", ".webp") + if ext not in allowed: + raise ValueError(f"signature photo extension not allowed: {ext}") + return f"{tenant_company_prefix(tenant_id, company_id)}signatures/{sid}/photo_{ts}{ext}" + + +def system_help_object_key(relative_path: str) -> str: + """ + Clave S3 completa bajo ``system/help/`` para un path relativo (p. ej. GET /files/...). + ``relative_path`` no debe empezar por / ni contener '..'. + """ + rel = relative_path.strip().lstrip("/") + if ".." in rel or not rel: + raise ValueError("invalid help object path") + return f"{SYSTEM_HELP_PREFIX}{rel}" diff --git a/backend/core/security.py b/backend/core/security.py new file mode 100644 index 0000000..cb188c9 --- /dev/null +++ b/backend/core/security.py @@ -0,0 +1,737 @@ +""" +Utilidades de seguridad y autenticación con Keycloak +""" + +import logging +from typing import Any, Dict, Optional, Set + +from fastapi import Depends, HTTPException, Request, Security +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from jose import JWTError, jwt +import httpx +from cachetools import TTLCache +from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError + +from .config import settings +from .database import get_core_db + +logger = logging.getLogger(__name__) + +# Cache para tokens verificados (1 minuto de TTL, máximo 1000 tokens) +token_cache = TTLCache(maxsize=1000, ttl=60) + +# IDs de tenants ya sincronizados en este proceso (evita consultas repetidas) +_synced_tenant_ids: Set[int] = set() + +# Alias Hub tenant_id -> tenant_id local cuando existe drift histórico de IDs +# (mismo slug, diferente id). +_tenant_id_aliases: Dict[int, int] = {} +# Inverso: id local core.tenants -> id tenant en Hub (JWT / client_tenants) para llamadas al Hub. +_tenant_id_hub_by_local: Dict[int, int] = {} + +# Security scheme +security = HTTPBearer() + +def get_active_system(request: Request) -> Optional[str]: + """Sistema activo: header ``X-Active-System`` o cookie ``active_system``.""" + return request.headers.get("x-active-system") or request.cookies.get("active_system") or None + + +async def verify_token(token: str, tenant_id_override: str = None) -> Dict[str, Any]: + """ + Verifica un token JWT llamando al Hub central. + Si DEV_LOCAL_AUTH=True y el token es HS256 local, lo verifica sin Hub. + """ + cache_key = (token, tenant_id_override) + if cache_key in token_cache: + return token_cache[cache_key] + + # Shortcut para tokens de desarrollo local + if settings.DEV_LOCAL_AUTH: + try: + header = jwt.get_unverified_header(token) + if header.get("alg") == "HS256": + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) + if payload.get("dev_local"): + token_cache[cache_key] = payload + return payload + except JWTError as e: + raise HTTPException(status_code=401, detail=f"Dev token inválido: {e}") + + try: + headers: Dict[str, str] = {"Authorization": f"Bearer {token}"} + if tenant_id_override: + headers["X-Tenant-Override"] = tenant_id_override + + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.get( + f"{settings.HUB_URL}api/v1/auth/me", + headers=headers + ) + + if response.status_code == 200: + user_info = response.json() + token_cache[cache_key] = user_info + return user_info + + logger.error(f"Hub token verification failed with status {response.status_code}") + raise HTTPException(status_code=401, detail="Could not validate credentials") + + except httpx.HTTPError as e: + logger.error(f"Hub unreachable or error during token verification: {str(e)}") + raise HTTPException(status_code=503, detail="Authentication service unavailable") + except Exception as e: + logger.error(f"Unexpected error during token verification: {str(e)}") + raise HTTPException(status_code=401, detail="Authentication error") + + +def _ensure_user_tenant_for_company( + db: Session, keycloak_user_id: str, tenant_id: int, company_id: int +) -> None: + """Garantiza fila core.user_tenants (usuario ↔ compañía ↔ tenant).""" + from api.v1.modules.core.user_tenant.models import UserTenant + + existing = ( + db.query(UserTenant) + .filter( + UserTenant.keycloak_user_id == keycloak_user_id, + UserTenant.tenant_id == tenant_id, + UserTenant.company_id == company_id, + ) + .first() + ) + if existing: + if not existing.is_active: + existing.is_active = True + db.commit() + return + db.add( + UserTenant( + keycloak_user_id=keycloak_user_id, + tenant_id=tenant_id, + company_id=company_id, + is_active=True, + ) + ) + db.commit() + + +def _ensure_company_exists( + db: Session, + tenant_id: int, + tenant_name: str, + hub_user: Optional[Dict[str, Any]] = None, +) -> None: + """ + STUB — implementa este método con el modelo de compañía de tu proyecto. + + Debe garantizar que exista al menos una empresa para el tenant y que el + usuario del token (hub_user["sub"]) tenga un registro en user_tenants. + """ + logger.debug( + "_ensure_company_exists: no implementado en la plantilla (tenant_id=%s)", tenant_id + ) + + +def _repair_user_company_link_if_needed( + db: Session, + tenant_id_effective: int, + hub_user: Optional[Dict[str, Any]], +) -> None: + """STUB — implementa con el modelo de compañía de tu proyecto.""" + if not hub_user or not hub_user.get("sub"): + return + from api.v1.modules.core.permissions.service import PermissionService + from api.v1.modules.core.user_tenant.models import UserTenant + + # Sin modelo de compañía en la plantilla, no hay empresa que verificar. + # Implementa esta función cuando definas tu tabla de compañías. + + +def _ensure_tenant_synced( + db: Session, + tenant_id: int, + tenant_slug: str, + hub_user: Optional[Dict[str, Any]] = None, +) -> int: + """ + Garantiza que el tenant del Hub exista en core.tenants local. + Se ejecuta una sola vez por tenant_id por ciclo de vida del proceso. + El Hub es la fuente de verdad — este método solo sincroniza en una dirección. + """ + if tenant_id in _synced_tenant_ids: + effective = int(_tenant_id_aliases.get(tenant_id, tenant_id)) + _repair_user_company_link_if_needed(db, effective, hub_user) + return effective + + try: + # Importación local para evitar imports circulares + from api.v1.modules.core.tenants.models import Tenant, TenantType + + name = " ".join(word.capitalize() for word in tenant_slug.replace("-", " ").split()) + + existing = db.query(Tenant).filter(Tenant.id == tenant_id).first() + if existing: + # Update name/slug/keycloak_realm if they differ (Hub is source of truth) + if existing.slug != tenant_slug or existing.name != name or existing.keycloak_realm != tenant_slug: + existing.slug = tenant_slug + existing.name = name + existing.keycloak_realm = tenant_slug + db.commit() + logger.info(f"Tenant id={tenant_id} actualizado: slug='{tenant_slug}'") + _synced_tenant_ids.add(tenant_id) + # Garantizar empresa aunque el tenant ya existiera + _ensure_company_exists(db, tenant_id, name, hub_user) + return tenant_id + + # Crear el tenant local con los datos disponibles del token. + # El Hub siempre crea el realm de Keycloak con el mismo nombre que el slug. + tenant = Tenant( + id=tenant_id, + name=name, + slug=tenant_slug, + type=TenantType.SHARED, + keycloak_realm=tenant_slug, + is_active=True, + ) + db.add(tenant) + db.commit() + _synced_tenant_ids.add(tenant_id) + logger.info(f"Tenant '{tenant_slug}' (id={tenant_id}) sincronizado desde Hub a core.tenants") + # Crear la empresa correspondiente al tenant recién sincronizado + _ensure_company_exists(db, tenant_id, name, hub_user) + return tenant_id + + except IntegrityError: + # Puede ser concurrencia o colisión de slug (id diferente, mismo slug) + db.rollback() + from api.v1.modules.core.tenants.models import Tenant + # Si el slug ya existe con diferente id, el tenant real del Hub no está registrado aún. + # Logueamos el conflicto para depuración; el sistema continuará con tenant_id vacío. + stale = db.query(Tenant).filter(Tenant.slug == tenant_slug).first() + if stale and stale.id != tenant_id: + logger.error( + f"Conflicto de tenant: JWT dice id={tenant_id} slug='{tenant_slug}', " + f"pero core.tenants tiene id={stale.id} mismo slug. " + f"Elimine el registro obsoleto con: " + f"DELETE FROM core.tenants WHERE id={stale.id};" + ) + # Auto-heal en runtime: mapear temporalmente al tenant local existente por slug + # para evitar dejar al usuario sin compañías y evitar este conflicto en cada request. + _tenant_id_aliases[tenant_id] = int(stale.id) + _tenant_id_hub_by_local[int(stale.id)] = int(tenant_id) + _synced_tenant_ids.add(tenant_id) + _ensure_company_exists(db, int(stale.id), stale.name or tenant_slug, hub_user) + return int(stale.id) + else: + _synced_tenant_ids.add(tenant_id) + return tenant_id + except Exception as e: + db.rollback() + logger.warning(f"No se pudo sincronizar tenant {tenant_id} ({tenant_slug}): {e}") + return _tenant_id_aliases.get(tenant_id, tenant_id) + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Security(security), + db: Session = Depends(get_core_db), + request: Request = None, +) -> Dict[str, Any]: + """ + Dependency para obtener el usuario actual desde el token JWT. + Auto-sincroniza el tenant en core.tenants si fue creado en el Hub + pero aún no existe en la BD local. + + Uso en FastAPI: + current_user: dict = Depends(get_current_user) + """ + token = credentials.credentials + + # Leer tenant override del header X-Tenant-Override (pasado por el SvelteKit server + # desde la cookie sso_tenant_id, flujo SSO relay multi-tenant) + tenant_override = request.headers.get('X-Tenant-Override') if request else None + + logger.info(f"[get_current_user] X-Tenant-Override={tenant_override!r}") + + user_info = await verify_token(token, tenant_id_override=tenant_override) + # Copia local para poder normalizar tenant_id sin mutar el objeto cacheado + user_info = dict(user_info) + + # Modo local: el token ya trae todo. Saltar sincronización con el Hub. + if settings.DEV_LOCAL_AUTH and user_info.get("dev_local"): + return user_info + + # Sincronizar tenant desde Hub a BD local (solo la primera vez por tenant) + tenant_id = user_info.get("tenant_id") + tenant_slug = user_info.get("tenant_slug") + if tenant_id and tenant_slug: + effective_tenant_id = _ensure_tenant_synced( + db, int(tenant_id), str(tenant_slug), hub_user=user_info + ) + if effective_tenant_id != int(tenant_id): + logger.warning( + f"[get_current_user] tenant_id ajustado por alias: hub={tenant_id} local={effective_tenant_id} slug={tenant_slug}" + ) + user_info["tenant_id"] = effective_tenant_id + + # Rehidratación de sesión: sincronización no bloqueante de avatar/perfil + # con cache corto para evitar llamadas excesivas al Hub. + try: + from core.workspace_profile_sync import sync_workspace_profile_for_user + + await sync_workspace_profile_for_user( + db, + access_token=token, + keycloak_user_id=user_info.get("sub"), + tenant_id=user_info.get("tenant_id"), + workspace_profile=user_info, + ) + except Exception as exc: + logger.warning("workspace_profile_sync_failed_on_get_current_user: %s", exc) + + return user_info + + +async def get_current_active_user( + current_user: Dict[str, Any] = Depends(get_current_user), +) -> Dict[str, Any]: + """ + Dependency para obtener usuario activo (puede incluir validaciones adicionales) + """ + # Aquí se pueden agregar validaciones adicionales + # Por ejemplo, verificar si el usuario está activo en la BD + return current_user + + +def has_role(required_role: str): + """ + Decorator/Dependency para verificar roles de usuario + + Uso: + @router.get("/admin") + async def admin_endpoint(user = Depends(has_role("admin"))): + ... + """ + + async def role_checker( + current_user: Dict[str, Any] = Depends(get_current_user), + ) -> Dict[str, Any]: + user_roles = collect_user_role_names(current_user) + + if required_role not in user_roles: + logger.warning( + "Role denied. Required: %s. User has: %s", + required_role, + sorted(user_roles), + ) + raise HTTPException( + status_code=403, + detail=f"User does not have required role: {required_role}", + ) + + return current_user + + return role_checker + + +def get_tenant_from_token(user_info: Dict[str, Any]) -> Optional[int]: + """ + Extrae el tenant_id del token JWT + + El tenant_id puede estar en diferentes lugares según configuración de Keycloak: + - En claims personalizados + - En el realm + - En atributos del usuario + """ + # Intentar obtener de claims personalizados + tenant_id = user_info.get("tenant_id") + if not tenant_id: + # Intentar obtener de atributos + tenant_id = user_info.get("attributes", {}).get("tenant_id") + + if tenant_id: + return int(tenant_id) + + return None + + +def resolve_hub_tenant_id_for_api( + local_tenant_id: Optional[int], x_tenant_override: Optional[str] +) -> int: + """ + ID de tenant en Hub (client_tenants) para llamadas a la API del Hub. + + Prioriza X-Tenant-Override (cookie SSO). Si hubo drift id Hub↔local, + usa el mapeo inverso registrado en _ensure_tenant_synced. + """ + if x_tenant_override and str(x_tenant_override).strip().isdigit(): + return int(str(x_tenant_override).strip()) + if local_tenant_id is None: + return 0 + lid = int(local_tenant_id) + return int(_tenant_id_hub_by_local.get(lid, lid)) + + +def resolve_effective_tenant_id_from_user(current_user: Dict[str, Any]) -> Optional[int]: + """ + tenant_id efectivo del usuario: claims del token vía get_tenant_from_token, + luego fallback a ``tenant_id`` plano del Hub (puede venir como lista). + + Contrato Hub: no es obligatorio que todo usuario tenga ``tenant_id`` en /auth/me; + el acceso por compañía puede basarse solo en RBAC local (ver ``user_has_app_company_membership``). + """ + tid = get_tenant_from_token(current_user) + if tid is not None: + return int(tid) + raw = current_user.get("tenant_id") + if raw is None: + return None + if isinstance(raw, list) and raw: + raw = raw[0] + try: + return int(raw) + except (TypeError, ValueError): + return None + + +def is_hub_admin(current_user: Dict[str, Any]) -> bool: + """True si el Hub atestigua que el usuario es hub_admin (super-admin global).""" + return bool(current_user.get("is_hub_admin")) + + +def _has_local_super_admin_role( + db: "Session", user_id: Optional[str], company_id: Optional[int] +) -> bool: + """ + True si el usuario tiene el rol local ``super_admin`` activo en la compañía. + + Sustituye al antiguo bypass por rol ``admin`` del realm Keycloak para + autorización: la fuente de verdad es la BD local (``core.user_company_roles`` + + ``core.company_roles``), no claims del JWT. La promoción automática de + admins de Keycloak a ``super_admin`` local sigue ocurriendo en el endpoint + ``/permissions/me`` (bootstrap), por lo que un admin del realm que entre + al sistema sigue obteniendo el bypass sin coordinación manual. + """ + if not user_id or not company_id: + return False + try: + from api.v1.modules.core.permissions.models import ( + CompanyRole, + UserCompanyRole, + ) + + return ( + db.query(UserCompanyRole) + .join(CompanyRole, CompanyRole.id == UserCompanyRole.company_role_id) + .filter( + UserCompanyRole.user_id == user_id, + UserCompanyRole.company_id == company_id, + UserCompanyRole.is_active == True, + CompanyRole.code == "super_admin", + CompanyRole.is_active == True, + ) + .first() + is not None + ) + except Exception as exc: + # Un fallo de BD aquí no debe escalar a acceso silenciosamente: + # se loguea y se trata como "no es super_admin" (deniega bypass). + logger.warning( + "has_local_super_admin_role_failed", + extra={ + "op": "has_local_super_admin_role", + "user_id": user_id, + "company_id": company_id, + "error": str(exc), + }, + ) + return False + + +def resolve_tenant_id_required( + current_user: Dict[str, Any], + db: Optional["Session"] = None, + company_id: Optional[int] = None, +) -> Optional[int]: + """ + Retorna el tenant_id efectivo o lanza 400. + Hub admin sin tenant_id en token: resuelve desde la empresa si company_id está disponible, + o retorna None como sentinel de acceso global (sin filtro de tenant). + """ + tid = get_tenant_from_token(current_user) + if tid is not None: + return int(tid) + raw = current_user.get("tenant_id") + if isinstance(raw, list) and raw: + raw = raw[0] + if raw is not None: + try: + return int(raw) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="Invalid tenant ID in token") + + if is_hub_admin(current_user): + # Sin modelo de compañía en la plantilla → acceso global sin filtro de tenant. + # Implementa la consulta a tu tabla de compañías si necesitas resolución exacta. + return None + + raise HTTPException(status_code=400, detail="Tenant ID not found in user data") + + +def user_has_app_company_membership( + db: Session, user_id: str, company_id: int +) -> bool: + """ + True si el usuario tiene fila activa en RBAC de la app o en core.user_tenants + para esa compañía (independiente del tenant en el JWT). + """ + if not user_id: + return False + try: + from api.v1.modules.core.permissions.models import ( + UserCompanyPermission, + UserCompanyRole, + ) + from api.v1.modules.core.user_tenant.models import UserTenant + + if ( + db.query(UserCompanyRole) + .filter( + UserCompanyRole.user_id == user_id, + UserCompanyRole.company_id == company_id, + UserCompanyRole.is_active == True, # noqa: E712 + ) + .first() + ): + return True + if ( + db.query(UserCompanyPermission) + .filter( + UserCompanyPermission.user_id == user_id, + UserCompanyPermission.company_id == company_id, + UserCompanyPermission.is_active == True, # noqa: E712 + ) + .first() + ): + return True + if ( + db.query(UserTenant) + .filter( + UserTenant.keycloak_user_id == user_id, + UserTenant.company_id == company_id, + UserTenant.is_active == True, # noqa: E712 + ) + .first() + ): + return True + except Exception as e: + logger.error("Error checking app company membership: %s", e) + return False + return False + + +def collect_company_ids_from_app_membership( + db: Session, user_id: str +) -> Set[int]: + """IDs de compañía donde el usuario tiene rol, permiso directo o user_tenants.""" + ids: Set[int] = set() + if not user_id: + return ids + try: + from api.v1.modules.core.permissions.models import ( + UserCompanyPermission, + UserCompanyRole, + ) + from api.v1.modules.core.user_tenant.models import UserTenant + + for (cid,) in ( + db.query(UserCompanyRole.company_id) + .filter( + UserCompanyRole.user_id == user_id, + UserCompanyRole.is_active == True, # noqa: E712 + ) + .distinct() + .all() + ): + ids.add(int(cid)) + for (cid,) in ( + db.query(UserCompanyPermission.company_id) + .filter( + UserCompanyPermission.user_id == user_id, + UserCompanyPermission.is_active == True, # noqa: E712 + ) + .distinct() + .all() + ): + ids.add(int(cid)) + for (cid,) in ( + db.query(UserTenant.company_id) + .filter( + UserTenant.keycloak_user_id == user_id, + UserTenant.is_active == True, # noqa: E712 + ) + .distinct() + .all() + ): + ids.add(int(cid)) + except Exception as e: + logger.error("Error collecting company ids from membership: %s", e) + return ids + + +def collect_user_role_names(current_user: Dict[str, Any]) -> Set[str]: + """ + Roles del usuario: primero la lista ``roles`` del Hub (GET /api/v1/auth/me + vía verify_token). Si no hay lista no vacía, se unen realm_access y + resource_access del JWT Keycloak clásico. + """ + names: Set[str] = set() + hub_roles = current_user.get("roles") + if isinstance(hub_roles, list): + names.update(str(r) for r in hub_roles if r is not None) + + if names: + return names + + realm = current_user.get("realm_access") + if isinstance(realm, dict): + names.update(str(r) for r in (realm.get("roles") or []) if r is not None) + for client in (current_user.get("resource_access") or {}).values(): + if isinstance(client, dict): + names.update(str(r) for r in (client.get("roles") or []) if r is not None) + return names + + +def validate_company_access( + db: Session, company_id: int, current_user: Dict[str, Any] +) -> bool: + """ + Valida acceso a la compañía: (1) tenant del token/Hub alineado con la empresa, o + (2) membership en la app (RBAC / user_tenants) para ese ``company_id``. + + El contrato con el Hub puede no incluir ``tenant_id`` para todos los usuarios; + en ese caso el acceso se basa en asignaciones en PostgreSQL. + """ + user_id = current_user.get("sub") or current_user.get("id") + if user_id and user_has_app_company_membership(db, str(user_id), company_id): + return True + + tenant_id = resolve_effective_tenant_id_from_user(current_user) + if not tenant_id: + return False + + # Sin modelo de compañía en la plantilla → delega solo en user_has_app_company_membership. + # Implementa la consulta a tu tabla de compañías para validación estricta. + return True + + +def validate_access_to_resource( + db: Session, + company_id: int, + current_user: Dict[str, Any], + required_permissions: Optional[list[str]] = None, + require_all: bool = True, +) -> Optional[int]: + """ + Valida que el usuario tenga acceso a un recurso específico basado en company_id + y regresa el tenant_id. Opcionalmente verifica permisos. + + Args: + db: Sesión de base de datos + company_id: company_id asociado al recurso + current_user: Información del usuario actual desde el token + required_permissions: Lista opcional de permisos requeridos. Si es None, no verifica permisos. + require_all: Si True, requiere TODOS los permisos. Si False, requiere AL MENOS UNO. + + Returns: + tenant_id si el usuario tiene acceso + + Raises: + HTTPException: Si no hay tenant_id, no tiene acceso o no tiene los permisos requeridos + """ + + tenant_id = resolve_effective_tenant_id_from_user(current_user) + + # Bypass de checks de permisos: hub_admin (atestado por el Hub en /auth/me) + # o rol local "super_admin" en la compañía (fuente de verdad: BD de a76). + # Se reemplazó el antiguo "admin" in realm_access.roles para que la + # autorización deje de depender de claims del JWT. + user_id = current_user.get("sub") or current_user.get("id") + is_global_admin = is_hub_admin(current_user) or _has_local_super_admin_role( + db, user_id, company_id + ) + + # 🚪 EXCEPCIÓN ESPECIAL: Si es el endpoint /me, permitimos el paso para el Bootstrap + # Detectamos si no se requieren permisos (típico de /me) + is_me_endpoint = required_permissions is None + + if not is_global_admin and not is_me_endpoint: + if not validate_company_access(db, company_id, current_user): + raise HTTPException(status_code=403, detail="Access denied to this company") + + # Sin modelo de compañía en la plantilla no se puede resolver tenant_id desde company. + # Implementa esta lógica cuando definas tu tabla de compañías. + + # Si aún no hay tenant_id y no es admin, error 400 + if not tenant_id and not is_global_admin and not is_me_endpoint: + raise HTTPException(status_code=400, detail="Tenant ID not found in token") + + # Verificar permisos locales + if required_permissions: + if is_global_admin: + # hub_admin / super_admin local: siempre debe tener tenant_id resuelto + # cuando se exigen permisos; retornar 1 silenciosamente sería acceso + # al tenant equivocado. + if tenant_id is None: + raise HTTPException( + status_code=400, + detail="No se pudo resolver el tenant_id para la empresa especificada", + ) + return int(tenant_id) + + from api.v1.modules.core.permissions.service import PermissionService + user_id = current_user.get("sub") or current_user.get("id") + permission_service = PermissionService(db) + + has_access = False + if require_all: + has_access = permission_service.has_all_permissions(user_id, company_id, required_permissions) + else: + has_access = permission_service.has_any_permission(user_id, company_id, required_permissions) + + # 🛡️ MEJORA DEV: Auto-bootstrap si falla el acceso en desarrollo + if not has_access and settings.ENVIRONMENT == "development": + try: + # Si el usuario no tiene roles asignados, intentamos el bootstrap + # bootstrap_super_admin solo asigna el rol si no tiene ninguno (o es admin) + permission_service.bootstrap_super_admin(user_id, company_id) + # Re-validar + if require_all: + has_access = permission_service.has_all_permissions(user_id, company_id, required_permissions) + else: + has_access = permission_service.has_any_permission(user_id, company_id, required_permissions) + + if has_access: + logger.info( + "Auto-bootstrap exitoso para user_id=%s company_id=%s", user_id, company_id + ) + except Exception as e: + logger.warning( + "Error en auto-bootstrap de seguridad user_id=%s company_id=%s: %s", + user_id, company_id, e, + ) + + if not has_access: + raise HTTPException(status_code=403, detail="Permission denied") + + # Nunca sustituir tenant_id=None/0 silenciosamente — un valor inválido aquí + # significaría acceso al tenant equivocado. Si llegamos aquí sin tenant_id + # válido para un usuario no-admin, es un estado inconsistente que debe fallar. + if not isinstance(tenant_id, int) or tenant_id <= 0: + if not is_global_admin: + raise HTTPException( + status_code=400, + detail="No se pudo determinar el tenant_id para la empresa especificada", + ) + return tenant_id # puede ser None solo para hub_admin sin required_permissions (acceso global) diff --git a/backend/core/storage_s3.py b/backend/core/storage_s3.py new file mode 100644 index 0000000..c0c7ef0 --- /dev/null +++ b/backend/core/storage_s3.py @@ -0,0 +1,226 @@ +""" +Cliente S3 (MinIO): bucket, objetos genéricos, presign, imports CSV. + +Las claves de objeto deben generarse con ``core.s3_keys`` (p. ej. ``csv_import_key`` vía +``s3_key_for_csv_import``); no construir prefijos ``tenants/...`` aquí. +""" +import logging +from typing import Any, Dict, List, Optional + +import boto3 +from botocore.config import Config +from botocore.exceptions import ClientError + +from core.config import settings +from core.s3_keys import csv_import_key + +logger = logging.getLogger(__name__) + + +def _client(): + return boto3.client( + "s3", + endpoint_url=settings.S3_ENDPOINT_URL, + aws_access_key_id=settings.S3_ACCESS_KEY, + aws_secret_access_key=settings.S3_SECRET_KEY, + region_name=settings.S3_REGION, + use_ssl=settings.S3_USE_SSL, + config=Config( + signature_version="s3v4", + s3={"addressing_style": "path"}, + ), + ) + + +def should_ensure_s3_bucket() -> bool: + return settings.use_s3_object_storage + + +def ensure_s3_bucket() -> None: + """Crea el bucket si no existe (idempotente).""" + if not should_ensure_s3_bucket(): + return + bucket = settings.S3_BUCKET + client = _client() + try: + client.head_bucket(Bucket=bucket) + logger.info("S3 bucket %s exists", bucket) + return + except ClientError as e: + code = e.response.get("Error", {}).get("Code", "") + if code not in ("404", "NoSuchBucket", "403"): + logger.warning("head_bucket %s: %s", bucket, e) + try: + if settings.S3_REGION == "us-east-1": + client.create_bucket(Bucket=bucket) + else: + client.create_bucket( + Bucket=bucket, + CreateBucketConfiguration={"LocationConstraint": settings.S3_REGION}, + ) + logger.info("S3 bucket %s created", bucket) + except ClientError as e: + logger.error("create_bucket %s failed: %s", bucket, e) + raise + + +# Alias para código existente +def ensure_csv_import_bucket() -> None: + ensure_s3_bucket() + + +def put_object_bytes(key: str, body: bytes, content_type: str = "application/octet-stream") -> None: + _client().put_object( + Bucket=settings.S3_BUCKET, + Key=key, + Body=body, + ContentType=content_type, + ) + + +def put_csv_object(key: str, body: bytes, content_type: str = "text/csv") -> None: + put_object_bytes(key, body, content_type=content_type) + + +def get_object_bytes(key: str) -> bytes: + resp = _client().get_object(Bucket=settings.S3_BUCKET, Key=key) + return resp["Body"].read() + + +def delete_object_if_exists(key: str) -> None: + try: + _client().delete_object(Bucket=settings.S3_BUCKET, Key=key) + except ClientError as e: + logger.warning("delete_object %s: %s", key, e) + + +def delete_objects_with_prefix(prefix: str, batch_size: int = 1000) -> None: + """ + Elimina en cascada todos los objetos cuyo Key empieza con `prefix`. + + Pensado para limpiar recursos ligados a una entidad (por ejemplo, + todos los objetos de una compañía bajo `tenants/{tid}/companies/{cid}/`). + """ + if not settings.use_s3_object_storage: + return + + client = _client() + continuation_token: Optional[str] = None + + while True: + params: Dict[str, Any] = { + "Bucket": settings.S3_BUCKET, + "Prefix": prefix, + "MaxKeys": max(1, min(int(batch_size), 1000)), + } + if continuation_token: + params["ContinuationToken"] = continuation_token + + try: + resp = client.list_objects_v2(**params) + except ClientError as e: + logger.warning("list_objects_v2 for prefix %s failed: %s", prefix, e) + break + + contents = resp.get("Contents") or [] + if not contents: + break + + to_delete = [{"Key": obj.get("Key")} for obj in contents if obj.get("Key")] + if to_delete: + try: + client.delete_objects( + Bucket=settings.S3_BUCKET, + Delete={"Objects": to_delete, "Quiet": True}, + ) + except ClientError as e: + logger.warning( + "delete_objects_with_prefix %s (batch_size=%s) failed: %s", + prefix, + len(to_delete), + e, + ) + + if not resp.get("IsTruncated"): + break + + continuation_token = resp.get("NextContinuationToken") + + +def object_exists(key: str) -> bool: + try: + _client().head_object(Bucket=settings.S3_BUCKET, Key=key) + return True + except ClientError: + return False + + +def presigned_get_url(key: str, expires_in: Optional[int] = None) -> str: + sec = expires_in if expires_in is not None else settings.S3_PRESIGNED_EXPIRES_SECONDS + return _client().generate_presigned_url( + "get_object", + Params={"Bucket": settings.S3_BUCKET, "Key": key}, + ExpiresIn=sec, + ) + + +def list_objects_tree( + prefix: str, + delimiter: str = "/", + max_keys: int = 100, + continuation_token: Optional[str] = None, +) -> Dict[str, Any]: + """ + Lista objetos/prefijos como árbol virtual. + + Retorna: + - ``prefixes``: subcarpetas (CommonPrefixes) + - ``objects``: objetos directos bajo ``prefix`` + - ``next_continuation_token`` y ``is_truncated`` para paginación + """ + params: Dict[str, Any] = { + "Bucket": settings.S3_BUCKET, + "Prefix": prefix, + "Delimiter": delimiter, + "MaxKeys": max(1, min(int(max_keys), 500)), + } + if continuation_token: + params["ContinuationToken"] = continuation_token + + resp = _client().list_objects_v2(**params) + common_prefixes: List[str] = [ + p.get("Prefix", "") for p in (resp.get("CommonPrefixes") or []) if p.get("Prefix") + ] + objects: List[Dict[str, Any]] = [] + for obj in resp.get("Contents") or []: + key = obj.get("Key") + if not key: + continue + if key == prefix: + # Marcador de carpeta (objeto vacío con mismo nombre del prefijo). + continue + objects.append( + { + "key": key, + "size": int(obj.get("Size", 0) or 0), + "last_modified": obj.get("LastModified"), + "etag": obj.get("ETag"), + "storage_class": obj.get("StorageClass"), + } + ) + + return { + "prefixes": common_prefixes, + "objects": objects, + "next_continuation_token": resp.get("NextContinuationToken"), + "is_truncated": bool(resp.get("IsTruncated")), + } + + +def s3_key_for_csv_import( + tenant_id, + company_id: int, + job_type: str, + job_id: str, +) -> str: + return csv_import_key(tenant_id, company_id, job_type, job_id) diff --git a/backend/core/workspace_profile_client.py b/backend/core/workspace_profile_client.py new file mode 100644 index 0000000..bcc31d7 --- /dev/null +++ b/backend/core/workspace_profile_client.py @@ -0,0 +1,80 @@ +import asyncio +import logging +from typing import Any, Optional + +import httpx + +from core.config import settings + +logger = logging.getLogger(__name__) + + +class WorkspaceProfileClient: + """Cliente para consultar perfil del usuario en Workspace Hub (/v1/auth/me).""" + + def __init__( + self, + base_url: Optional[str] = None, + timeout_ms: Optional[int] = None, + retries: int = 2, + transport: Optional[httpx.BaseTransport] = None, + ): + self.base_url = (base_url or settings.hub_api_base_url).rstrip("/") + self.timeout_s = max(0.1, float(timeout_ms or settings.HUB_PROFILE_SYNC_TIMEOUT_MS) / 1000.0) + self.retries = max(0, int(retries)) + self.transport = transport + + async def get_me(self, access_token: str) -> dict[str, Any]: + if not access_token: + raise ValueError("access_token is required") + + headers = {"Authorization": f"Bearer {access_token}"} + url = f"{self.base_url}/v1/auth/me" + + last_error: Optional[Exception] = None + for attempt in range(self.retries + 1): + try: + async with httpx.AsyncClient( + timeout=self.timeout_s, + transport=self.transport, + ) as client: + response = await client.get(url, headers=headers) + + if response.status_code == 200: + payload = response.json() + if not isinstance(payload, dict): + raise ValueError("Invalid workspace profile payload") + return payload + + if response.status_code in (401, 403, 404): + # Errores de autenticación/autorización o endpoint no disponible: + # no vale la pena reintentar. + raise httpx.HTTPStatusError( + f"Workspace profile request failed with status {response.status_code}", + request=response.request, + response=response, + ) + + # Reintentar solo para errores transitorios 5xx. + if response.status_code >= 500 and attempt < self.retries: + await asyncio.sleep(0.15 * (attempt + 1)) + continue + + raise httpx.HTTPStatusError( + f"Workspace profile request failed with status {response.status_code}", + request=response.request, + response=response, + ) + + except (httpx.TimeoutException, httpx.NetworkError) as exc: + last_error = exc + if attempt >= self.retries: + break + await asyncio.sleep(0.15 * (attempt + 1)) + except Exception as exc: + last_error = exc + break + + if last_error: + raise last_error + raise RuntimeError("Workspace profile request failed") diff --git a/backend/core/workspace_profile_sync.py b/backend/core/workspace_profile_sync.py new file mode 100644 index 0000000..9f34e89 --- /dev/null +++ b/backend/core/workspace_profile_sync.py @@ -0,0 +1,114 @@ +import logging +from datetime import datetime, timedelta, timezone +from typing import Any, Optional +from urllib.parse import urlparse + +from sqlalchemy.orm import Session + +from api.v1.modules.core.user_tenant.models import UserTenant +from core.workspace_profile_client import WorkspaceProfileClient + +logger = logging.getLogger(__name__) + +SYNC_TTL_SECONDS = 300 + + +def _is_valid_http_url(url: Optional[str]) -> bool: + if not url or not isinstance(url, str): + return False + parsed = urlparse(url.strip()) + return parsed.scheme in ("http", "https") and bool(parsed.netloc) + + +def _is_fresh(ts: Optional[datetime], ttl_seconds: int = SYNC_TTL_SECONDS) -> bool: + if not ts: + return False + now = datetime.now(timezone.utc) + if ts.tzinfo is None: + ts = ts.replace(tzinfo=timezone.utc) + return ts >= (now - timedelta(seconds=ttl_seconds)) + + +async def sync_workspace_profile_for_user( + db: Session, + *, + access_token: Optional[str], + keycloak_user_id: Optional[str], + tenant_id: Optional[int] = None, + company_id: Optional[int] = None, + workspace_profile: Optional[dict[str, Any]] = None, + force: bool = False, +) -> None: + """ + Sincroniza sub/avatar_url desde Workspace hacia core.user_tenants. + Nunca lanza excepción para no bloquear login ni requests autenticados. + """ + if not access_token or not keycloak_user_id: + return + + try: + query = db.query(UserTenant).filter( + UserTenant.keycloak_user_id == keycloak_user_id, + UserTenant.is_active == True, + ) + if tenant_id is not None: + query = query.filter(UserTenant.tenant_id == int(tenant_id)) + if company_id is not None: + query = query.filter(UserTenant.company_id == int(company_id)) + + target = query.first() + if not target: + return + + if not force and _is_fresh(target.workspace_profile_synced_at): + return + + payload = workspace_profile + if payload is None: + client = WorkspaceProfileClient() + payload = await client.get_me(access_token) + + workspace_sub = payload.get("sub") + if not workspace_sub: + logger.warning( + "workspace_profile_sync_warning", + extra={ + "event": "workspace_profile_sync_warning", + "reason": "missing_sub", + "keycloak_user_id": keycloak_user_id, + "tenant_id": target.tenant_id, + }, + ) + return + + avatar_url = payload.get("avatar_url") + sanitized_avatar = avatar_url.strip() if isinstance(avatar_url, str) else None + if sanitized_avatar and not _is_valid_http_url(sanitized_avatar): + logger.warning( + "workspace_profile_sync_warning", + extra={ + "event": "workspace_profile_sync_warning", + "reason": "invalid_avatar_url", + "keycloak_user_id": keycloak_user_id, + "tenant_id": target.tenant_id, + }, + ) + sanitized_avatar = None + + target.workspace_user_id = str(workspace_sub) + target.workspace_avatar_url = sanitized_avatar + target.workspace_profile_synced_at = datetime.now(timezone.utc) + db.add(target) + db.commit() + except Exception as exc: + db.rollback() + logger.warning( + "workspace_profile_sync_failed", + extra={ + "event": "workspace_profile_sync_failed", + "error": str(exc), + "keycloak_user_id": keycloak_user_id, + "tenant_id": tenant_id, + "company_id": company_id, + }, + ) diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh new file mode 100644 index 0000000..bcd9d7c --- /dev/null +++ b/backend/docker-entrypoint.sh @@ -0,0 +1,50 @@ +#!/bin/bash +set -e + +# Arranque del contenedor: espera a dependencias, luego ejecuta CMD (uvicorn, gunicorn, celery, …) + +# Función para esperar a un puerto TCP usando Python +wait_for_tcp() { + local host=$1 + local port=$2 + local service=$3 + local max_attempts=30 + local attempt=1 + + echo "Esperando a que $service esté disponible en ${host}:${port}..." + + while [ $attempt -le $max_attempts ]; do + if python3 -c "import socket; s = socket.socket(); s.settimeout(3); s.connect(('$host', $port)); s.close()" 2>/dev/null; then + echo "✓ $service está listo y accesible" + return 0 + fi + + echo "$service no está listo aún... (intento $attempt/$max_attempts)" + attempt=$((attempt + 1)) + sleep 2 + done + + echo "⚠ WARNING: $service no estuvo disponible después de $max_attempts intentos" + echo " Continuando de todas formas..." + return 0 +} + +DB_HOST_PRIMARY="${CORE_DB_HOST:-postgres-a76}" +DB_PORT="${CORE_DB_PORT:-5432}" + +if ! wait_for_tcp "$DB_HOST_PRIMARY" "$DB_PORT" "PostgreSQL"; then + # Fallback para entornos donde Docker solo registra el nombre del contenedor. + if [[ "$DB_HOST_PRIMARY" == "postgres-a76" ]]; then + wait_for_tcp "anexo76-postgres-a76" "$DB_PORT" "PostgreSQL" || true + else + echo " Continuando de todas formas..." + fi +fi + +# Keycloak solo se espera si se habilita explícitamente (ej. entorno Hub completo). +if [[ "${WAIT_FOR_KEYCLOAK:-0}" == "1" ]]; then + wait_for_tcp "${KEYCLOAK_SERVICE_HOST:-keycloak}" "${KEYCLOAK_SERVICE_PORT:-8080}" "Keycloak" || true +fi + +echo "Iniciando proceso: $*" +exec "$@" diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..f4a7990 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,136 @@ +""" +Mi Aplicación +Backend API con FastAPI + Keycloak + SQLAlchemy + """ + +import logging +import subprocess +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from pathlib import Path +from contextlib import asynccontextmanager + +# Core Modules (Secondary) +import core.celery_app # Initialize Celery App +from api.v1.router import router as api_v1_router +from core.config import settings +from core.storage_s3 import ensure_s3_bucket +from core.paths import layout_path +from core.error_handlers import register_exception_handlers +from core.middleware import ( + LicenseValidationMiddleware, + RequestLoggingMiddleware, + TenantMiddleware, +) + +# Configurar logging +logging.basicConfig( + level=logging.INFO if not settings.DEBUG else logging.DEBUG, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) + +# Crear aplicación FastAPI +app = FastAPI( + title="Mi Aplicación API", + version=settings.APP_VERSION, + description="Aplicación web multi-tenant con autenticación Workspace", + docs_url="/api/docs" if settings.DEBUG else None, + redoc_url="/api/redoc" if settings.DEBUG else None, + openapi_url="/api/openapi.json" if settings.DEBUG else None, +) + +logger = logging.getLogger(__name__) + +# Registrar manejadores de excepciones +register_exception_handlers(app) + +def run_migrations(): + subprocess.run(["alembic", "upgrade", "head"], check=True) + +# Inicializar la base de datos +async def on_startup(): + """Evento de inicio de la aplicación""" + logger.info("Iniciando la aplicación...") + #init_db() + run_migrations() + if settings.use_s3_object_storage: + ensure_s3_bucket() + logger.info("Base de datos inicializada correctamente.") + + +# Agregar middlewares personalizados +if settings.DEBUG: + app.add_middleware(RequestLoggingMiddleware) + +app.add_middleware(TenantMiddleware) +app.add_middleware(LicenseValidationMiddleware) + +# CORS debe ser el último en añadirse para que sea el más externo +# y cubra todas las respuestas, incluyendo las de los middlewares internos +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins_list, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Centraliza startup para evitar on_event() (deprecated en FastAPI) + await on_startup() + yield + +app.router.lifespan_context = lifespan + + +# Crear directorio de uploads si no existe y montar archivos estáticos +uploads_dir = Path("uploads").resolve() +uploads_dir.mkdir(parents=True, exist_ok=True) +app.mount("/api/uploads", StaticFiles(directory=str(uploads_dir)), name="uploads") + +# Directorios para importación CSV (layouts: temp y errors) +Path(layout_path("imports", "temp")).mkdir(parents=True, exist_ok=True) +Path(layout_path("imports", "errors")).mkdir(parents=True, exist_ok=True) + +# Registrar routers +app.include_router(api_v1_router, prefix="/api/v1") + + +@app.get("/api/") +async def root(): + """Root endpoint""" + return { + "name": "Mi Aplicación API", + "version": settings.APP_VERSION, + "status": "running", + "docs": "/api/docs" if settings.DEBUG else "disabled in production", + } + + +@app.get("/api/health") +async def health_check(): + """Health check endpoint""" + return {"status": "healthy", "environment": settings.ENVIRONMENT} + + +@app.get("/api/version") +async def get_version(): + """ + Endpoint de versión de la aplicación + + Retorna la versión de la aplicación que fue incrustada en la imagen Docker + durante el proceso de CI/CD. La versión se genera automáticamente según la rama: + - development: YY.MM.1. + - main: YY.MM.0. + + Returns: + dict: Información de versión y entorno + """ + return { + "service": settings.APP_NAME, + "version": settings.APP_VERSION, + "environment": settings.ENVIRONMENT, + "debug": settings.DEBUG, + } diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..0001d2f --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,57 @@ +# Core Framework +fastapi==0.119.0 +uvicorn[standard]==0.37.0 +gunicorn==23.0.0 +pydantic==2.12.3 +pydantic[email]==2.12.3 +pydantic-settings==2.11.0 + +# Database +sqlalchemy==2.0.44 +alembic==1.17.0 +psycopg2-binary==2.9.11 +asyncpg==0.30.0 + +# Authentication & Authorization +cachetools==5.5.0 +python-jose[cryptography]==3.5.0 +passlib[bcrypt]==1.7.4 + +# HTTP & API +httpx==0.28.1 +requests==2.32.5 +boto3==1.35.36 + + +# Utilities +python-multipart==0.0.20 +python-dotenv==1.1.1 +tenacity==9.1.2 + +# Monitoring & Logging +prometheus-client==0.23.1 +python-json-logger==4.0.0 + +# Development +pytest==8.4.2 +pytest-asyncio==1.2.0 +pytest-cov==7.0.0 +black==25.9.0 +flake8==7.3.0 +mypy==1.18.2 +pylint==4.0.2 + +# reportes +Jinja2==3.1.6 +pdfkit==1.0.0 +openpyxl==3.1.5 + +# Desarrollo en seguno plano +celery==5.3.6 +redis==5.0.1 +flower==2.0.1 + +# Barcode +pdf417gen==0.8.1 +asgiref==3.8.1 +aiosmtplib==3.0.1 diff --git a/backend/setup.cfg b/backend/setup.cfg new file mode 100644 index 0000000..7914d61 --- /dev/null +++ b/backend/setup.cfg @@ -0,0 +1,29 @@ +[coverage:run] +# Medir solo código fuente de la aplicación, no alembic, tests, ni artefactos +source = + api + core + +omit = + */alembic/* + */tests/* + */uploads/* + */layouts/* + */celerybeat-schedule* + main.py + */__init__.py + +[coverage:report] +omit = + */alembic/* + */tests/* + */uploads/* + */layouts/* + */celerybeat-schedule* + main.py + */__init__.py +# Excluir líneas de defensa estándar que no son alcanzables en tests +exclude_lines = + pragma: no cover + if __name__ == .__main__.: + raise NotImplementedError diff --git a/docker-compose.ci.yml b/docker-compose.ci.yml new file mode 100644 index 0000000..61f0039 --- /dev/null +++ b/docker-compose.ci.yml @@ -0,0 +1,41 @@ +# Override para CI/Jenkins (E2E + pipeline). +# +# Problema que resuelve: en Jenkins los bind mounts `./backend:/app` y +# `./frontend:/app` del compose base NO funcionan cuando el agente corre en un +# contenedor y el docker daemon del host no ve `$WORKSPACE`. El daemon monta +# un directorio vacío sobre `/app` y la app arranca sin código. +# +# Estrategia: con `!override` se reemplaza por completo la lista de volumes de +# cada servicio, dejando solo los volúmenes nombrados (cache, uploads, +# node_modules). El código se usa desde la imagen construida por +# `docker compose build`, que sí se transfiere por el daemon API. +# +# Requiere Docker Compose v2.24+ (tag `!override`). +services: + backend: + volumes: !override + - backend_cache:/app/__pycache__ + - backend_uploads:/app/uploads + + frontend: + volumes: !override + - frontend_node_modules:/app/node_modules + + celery_worker: + volumes: !override + - backend_cache:/app/__pycache__ + - backend_uploads:/app/uploads + + celery_beat: + volumes: !override + - backend_cache:/app/__pycache__ + - backend_uploads:/app/uploads + +# En Jenkins/agents no existe la red externa de aduanasoft-hub (desarrollo local). +# Sin esto, `docker compose up` falla: "aduanasoft-hub_default ... could not be found". +# Red bridge propia por proyecto (COMPOSE_PROJECT_NAME) para E2E/CI en paralelo. +networks: + hub-net: + driver: bridge + external: false + name: ${COMPOSE_PROJECT_NAME:-anexo76-ci}_hub_net diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml new file mode 100644 index 0000000..9bdb1dd --- /dev/null +++ b/docker-compose.e2e.yml @@ -0,0 +1,166 @@ +# Stack efímero para pruebas E2E (Playwright) en CI. +# Levantado por Jenkinsfile (stage "E2E (Playwright)") ANTES del deploy a dev, +# para validar la imagen recién buildeada contra una DB limpia y servicios aislados. +# +# Diferencias clave vs docker-compose.prod.yml: +# - Sin volúmenes persistidos (todo se descarta en `down -v`). +# - Sin `container_name` ni `restart` (efímero, COMPOSE_PROJECT_NAME aísla los nombres). +# - Sin `celery_worker` / `celery_beat` (no se ejercitan en los specs actuales). +# - Imágenes vía env vars: la del frontend se rebuildea localmente con +# VITE_API_URL=http://localhost:8000/api/ (la prod tiene la URL de dev bakeada). +# - Puertos fijos: frontend 5173 / backend 8000 (URIs ya registradas en Workspace). +# +# Variables requeridas en el entorno al invocar docker compose: +# E2E_BACKEND_IMAGE — imagen del backend recién pusheada a Harbor +# E2E_FRONTEND_IMAGE — imagen temporal del frontend con VITE_API_URL=localhost + +services: + postgres-app: + image: postgres:18-alpine + environment: + POSTGRES_DB: app_core + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_INITDB_ARGS: "--encoding=UTF8" + networks: + - e2e-net + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d app_core || exit 1"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 20s + # Postgres 18+ requiere mount en /var/lib/postgresql (padre), no en /data. + # Ver https://github.com/docker-library/postgres/pull/1259 — la imagen detecta + # el mount viejo como "unused" y aborta el arranque. + tmpfs: + - /var/lib/postgresql + + minio: + image: minio/minio:RELEASE.2025-09-07T16-13-09Z + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + networks: + - e2e-net + healthcheck: + test: ["CMD-SHELL", "curl -f http://127.0.0.1:9000/minio/health/live || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s + tmpfs: + - /data + + valkey: + image: valkey/valkey:7.2 + networks: + - e2e-net + + backend: + image: ${E2E_BACKEND_IMAGE} + environment: + - DEBUG=False + - ENVIRONMENT=e2e + - PYTHONUNBUFFERED=1 + - PYTHONDONTWRITEBYTECODE=1 + - CORE_DB_HOST=postgres-app + - CORE_DB_PORT=5432 + - CORE_DB_NAME=app_core + - CORE_DB_USER=postgres + - CORE_DB_PASSWORD=postgres + # Keycloak — apunta a Workspace real; el cliente anexo76-frontend ya + # tiene http://localhost:5173/auth/callback como redirect URI permitida. + - KEYCLOAK_SERVER_URL=https://workspace.aduanasoft.com/kcauth + - KEYCLOAK_REALM=master + - KEYCLOAK_CLIENT_ID=anexo76-frontend + - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-} + - CORS_ORIGINS=http://localhost:5173 + - VALKEY_URL=redis://valkey:6379/0 + - HUB_URL=https://workspace.aduanasoft.com + - APP_PUBLIC_URL=http://localhost:5173 + # SITAR — proveedor del tipo de cambio (botón "Consultar DOF" del dialog + # de ExchangeRateGuard). Sin estas vars el dialog nunca llena el input + # y auth.setup.ts falla. Las credenciales se inyectan desde Jenkins. + - SITAR_API_URL=${SITAR_API_URL:-} + - SITAR_API_USER=${SITAR_API_USER:-} + - SITAR_API_PASSWORD=${SITAR_API_PASSWORD:-} + - CSV_IMPORT_STORAGE=minio + - S3_ENDPOINT_URL=http://minio:9000 + - S3_ACCESS_KEY=minioadmin + - S3_SECRET_KEY=minioadmin + - S3_BUCKET=anexo76 + - S3_REGION=us-east-1 + - S3_USE_SSL=false + - S3_FILE_STORAGE=true + ports: + - "8000:8000" + depends_on: + postgres-app: + condition: service_healthy + minio: + condition: service_healthy + networks: + - e2e-net + # Replica el comando de prod (gunicorn) para que el test ejerza el mismo runtime + # que se desplegará. El CMD del Dockerfile usa uvicorn --reload (modo dev). + command: + - gunicorn + - main:app + - -k + - uvicorn.workers.UvicornWorker + - -w + - "1" + - -b + - 0.0.0.0:8000 + - --log-level + - info + - --forwarded-allow-ips + - "*" + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8000/api/health || exit 1"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 60s + + frontend: + image: ${E2E_FRONTEND_IMAGE} + environment: + - NODE_ENV=production + # VITE_API_URL ya está bakeada en E2E_FRONTEND_IMAGE; este valor solo sirve + # para fallback server-side en frontend/src/lib/server/api.ts. + - VITE_API_URL=http://localhost:8000/api/ + - INTERNAL_API_URL=http://backend:8000/api/ + - INTERNAL_HUB_URL=https://workspace.aduanasoft.com + - HUB_URL=https://workspace.aduanasoft.com + - VITE_HUB_URL=https://workspace.aduanasoft.com + - VITE_KEYCLOAK_URL=https://workspace.aduanasoft.com/kcauth + - VITE_KEYCLOAK_REALM=master + - VITE_KEYCLOAK_CLIENT_ID=anexo76-frontend + - KEYCLOAK_URL=https://workspace.aduanasoft.com/kcauth + - KEYCLOAK_REALM=master + - KEYCLOAK_CLIENT_ID=anexo76-frontend + - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-} + # ORIGIN controla url.origin y el flag secure de cookies — debe coincidir con la URL + # registrada en Workspace para que el callback de Keycloak resuelva correctamente. + - ORIGIN=http://localhost:5173 + - SITE_URL=http://localhost:5173 + ports: + - "5173:5173" + depends_on: + backend: + condition: service_healthy + networks: + - e2e-net + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:5173/ || exit 1"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + +networks: + e2e-net: + driver: bridge diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..c3f08ee --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,313 @@ +services: + # PostgreSQL - Base de datos core (app) + postgres-app: + image: postgres:18-alpine + container_name: app-postgres-app + environment: + POSTGRES_DB: app_core + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ${POSTGRES_APP_PASSWORD:-postgres} + POSTGRES_INITDB_ARGS: "--encoding=UTF8" + ports: + - "5939:5432" + volumes: + - postgres_app_data:/var/lib/postgresql/data + networks: + - backend-net + restart: unless-stopped + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U postgres -d app_core || exit 1" ] + interval: 5s + timeout: 3s + retries: 10 + start_period: 20s + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + deploy: + resources: + limits: + memory: 512M + reservations: + memory: 256M + shm_size: 128mb + + # Backend - FastAPI + backend: + image: dev.aduanasoft.com/anexo76/backend:latest + container_name: app-backend + environment: + - DEBUG=${DEBUG:-True} + - ENVIRONMENT=${ENVIRONMENT:-production} + - PYTHONUNBUFFERED=1 + - PYTHONDONTWRITEBYTECODE=1 + - CORE_DB_HOST=${CORE_DB_HOST:-postgres-a76} + - CORE_DB_PORT=${CORE_DB_PORT:-5432} + - CORE_DB_NAME=${CORE_DB_NAME:-app_core} + - CORE_DB_USER=${CORE_DB_USER:-postgres} + - CORE_DB_PASSWORD=${POSTGRES_APP_PASSWORD:-postgres} + # Keycloak — fuente única de autenticación: Workspace (workspace.aduanasoft.com) + - KEYCLOAK_SERVER_URL=${KEYCLOAK_SERVER_URL:-https://workspace.aduanasoft.com/kcauth} + - KEYCLOAK_REALM=${KEYCLOAK_REALM:-master} + - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-app-frontend} + - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-} + - CORS_ORIGINS=${CORS_ORIGINS:-https://anexo76-dev.aduanasoft.com,http://localhost:3000} + - SITAR_API_URL=${SITAR_API_URL} + - SITAR_API_USER=${SITAR_API_USER} + - SITAR_API_PASSWORD=${SITAR_API_PASSWORD} + - COVE_FIEL_HASH_KEY=${COVE_FIEL_HASH_KEY} + - COVE_FIEL_HASH_IV=${COVE_FIEL_HASH_IV} + - COVE_API_URL=${COVE_API_URL:-https://api.vu.aduanasoft.com} + - COVE_API_VERIFY_SSL=${COVE_API_VERIFY_SSL:-False} + - VALKEY_URL=${VALKEY_URL:-redis://valkey:6379/0} + - CENTRAL_SERVER_URL=${CENTRAL_SERVER_URL:-""} + - SYNC_SECRET_TOKEN=${SYNC_SECRET_TOKEN:-change-this-sync-token-in-production} + - SPOKE_URLS=${SPOKE_URLS:-""} + - HUB_URL=${HUB_URL:-https://workspace.aduanasoft.com} + - HUB_ADMIN_EMAIL=${HUB_ADMIN_EMAIL:-} + - HUB_ADMIN_PASSWORD=${HUB_ADMIN_PASSWORD:-} + - APP_PUBLIC_URL=${APP_PUBLIC_URL:-https://anexo76-dev.aduanasoft.com} + - SMTP_HOST=${SMTP_HOST:-smtp.gmail.com} + - SMTP_PORT=${SMTP_PORT:-587} + - SMTP_USER=${SMTP_USER:-} + - SMTP_PASSWORD=${SMTP_PASSWORD:-} + - SMTP_FROM_NAME=${SMTP_FROM_NAME:-Sistema Anexo76} + - SMTP_USE_TLS=${SMTP_USE_TLS:-true} + - CSV_IMPORT_STORAGE=${CSV_IMPORT_STORAGE:-minio} + - S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000} + - S3_ACCESS_KEY=${S3_ACCESS_KEY:-${MINIO_ROOT_USER:-minioadmin}} + - S3_SECRET_KEY=${S3_SECRET_KEY:-${MINIO_ROOT_PASSWORD:-minioadmin}} + - S3_BUCKET=${S3_BUCKET:-app} + - S3_REGION=${S3_REGION:-us-east-1} + - S3_USE_SSL=${S3_USE_SSL:-false} + - S3_FILE_STORAGE=${S3_FILE_STORAGE:-true} + - S3_PRESIGNED_EXPIRES_SECONDS=${S3_PRESIGNED_EXPIRES_SECONDS:-3600} + ports: + - "3467:8000" + depends_on: + postgres-app: + condition: service_healthy + minio: + condition: service_healthy + volumes: + - backend_uploads:/app/uploads + - backend_layouts:/app/layouts + networks: + - backend-net + - frontend-net + restart: unless-stopped + command: [ "gunicorn", "main:app", "-k", "uvicorn.workers.UvicornWorker", "-w", "${WEB_CONCURRENCY:-1}", "-b", "0.0.0.0:8000", "--log-level", "info", "--forwarded-allow-ips", "*" ] + healthcheck: + test: [ "CMD-SHELL", "curl -f http://localhost:8000/api/health || exit 1" ] + interval: 15s + timeout: 5s + retries: 5 + start_period: 60s + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + deploy: + resources: + limits: + memory: 512M + reservations: + memory: 256M + + # celery + celery_worker: + image: dev.aduanasoft.com/anexo76/backend:latest + container_name: worker + command: celery -A core.celery_app worker --loglevel=info + environment: + - VALKEY_URL=${VALKEY_URL:-redis://valkey:6379/0} + - CENTRAL_SERVER_URL=${CENTRAL_SERVER_URL:-""} + - SYNC_SECRET_TOKEN=${SYNC_SECRET_TOKEN:-change-this-sync-token-in-production} + - SPOKE_URLS=${SPOKE_URLS:-""} + - CORE_DB_HOST=${CORE_DB_HOST:-postgres-a76} + - CORE_DB_PORT=${CORE_DB_PORT:-5432} + - CORE_DB_NAME=${CORE_DB_NAME:-app_core} + - CORE_DB_USER=${CORE_DB_USER:-postgres} + - CORE_DB_PASSWORD=${POSTGRES_APP_PASSWORD:-postgres} + - SITAR_API_URL=${SITAR_API_URL} + - SITAR_API_USER=${SITAR_API_USER} + - SITAR_API_PASSWORD=${SITAR_API_PASSWORD} + - COVE_FIEL_HASH_KEY=${COVE_FIEL_HASH_KEY} + - COVE_FIEL_HASH_IV=${COVE_FIEL_HASH_IV} + - COVE_API_URL=${COVE_API_URL:-https://api.vu.aduanasoft.com} + - COVE_API_VERIFY_SSL=${COVE_API_VERIFY_SSL:-False} + + - CSV_IMPORT_STORAGE=${CSV_IMPORT_STORAGE:-minio} + - S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000} + - S3_ACCESS_KEY=${S3_ACCESS_KEY:-${MINIO_ROOT_USER:-minioadmin}} + - S3_SECRET_KEY=${S3_SECRET_KEY:-${MINIO_ROOT_PASSWORD:-minioadmin}} + - S3_BUCKET=${S3_BUCKET:-app} + - S3_REGION=${S3_REGION:-us-east-1} + - S3_USE_SSL=${S3_USE_SSL:-false} + - S3_FILE_STORAGE=${S3_FILE_STORAGE:-true} + depends_on: + - backend + - valkey + volumes: + - backend_layouts:/app/layouts + networks: + - backend-net + + celery_beat: + image: dev.aduanasoft.com/anexo76/backend:latest + container_name: celery_beat + command: celery -A core.celery_app beat --loglevel=info + environment: + - VALKEY_URL=${VALKEY_URL:-redis://valkey:6379/0} + - CENTRAL_SERVER_URL=${CENTRAL_SERVER_URL:-""} + - SYNC_SECRET_TOKEN=${SYNC_SECRET_TOKEN:-change-this-sync-token-in-production} + - SPOKE_URLS=${SPOKE_URLS:-""} + - CORE_DB_HOST=${CORE_DB_HOST:-postgres-a76} + - CORE_DB_PORT=${CORE_DB_PORT:-5432} + - CORE_DB_NAME=${CORE_DB_NAME:-app_core} + - CORE_DB_USER=${CORE_DB_USER:-postgres} + - CORE_DB_PASSWORD=${POSTGRES_APP_PASSWORD:-postgres} + - SITAR_API_URL=${SITAR_API_URL} + - SITAR_API_USER=${SITAR_API_USER} + - SITAR_API_PASSWORD=${SITAR_API_PASSWORD} + - COVE_FIEL_HASH_KEY=${COVE_FIEL_HASH_KEY} + - COVE_FIEL_HASH_IV=${COVE_FIEL_HASH_IV} + - COVE_API_URL=${COVE_API_URL:-https://api.vu.aduanasoft.com} + - COVE_API_VERIFY_SSL=${COVE_API_VERIFY_SSL:-False} + + - CSV_IMPORT_STORAGE=${CSV_IMPORT_STORAGE:-minio} + - S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000} + - S3_ACCESS_KEY=${S3_ACCESS_KEY:-${MINIO_ROOT_USER:-minioadmin}} + - S3_SECRET_KEY=${S3_SECRET_KEY:-${MINIO_ROOT_PASSWORD:-minioadmin}} + - S3_BUCKET=${S3_BUCKET:-app} + - S3_REGION=${S3_REGION:-us-east-1} + - S3_USE_SSL=${S3_USE_SSL:-false} + - S3_FILE_STORAGE=${S3_FILE_STORAGE:-true} + depends_on: + - backend + - valkey + volumes: + - backend_layouts:/app/layouts + networks: + - backend-net + restart: unless-stopped + + valkey: + image: valkey/valkey:7.2 + container_name: valkey + restart: always + ports: + - "6579:6379" + networks: + - backend-net + + minio: + image: minio/minio:RELEASE.2025-09-07T16-13-09Z + container_name: app-minio + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} + ports: + - "${MINIO_API_PORT:-9100}:9000" + - "${MINIO_CONSOLE_PORT:-9101}:9001" + volumes: + - minio_data:/data + networks: + - backend-net + restart: unless-stopped + healthcheck: + test: [ "CMD-SHELL", "curl -f http://127.0.0.1:9000/minio/health/live || exit 1" ] + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + # Frontend - SvelteKit + frontend: + image: dev.aduanasoft.com/anexo76/frontend:latest + container_name: app-frontend + environment: + - NODE_ENV=${NODE_ENV:-production} + - VITE_API_URL=${VITE_API_URL:-https://anexo76-dev.aduanasoft.com/api} + - INTERNAL_API_URL=${INTERNAL_API_URL:-http://backend:8000/api/} + - INTERNAL_HUB_URL=https://workspace.aduanasoft.com + - HUB_URL=https://workspace.aduanasoft.com + # VITE_HUB_URL: URL pública del Hub para el browser (bakeada en build, pero también se lee + # en runtime por $env/dynamic/private en workspace-auth.ts). El root .env puede tener + # localhost; isDevOnlyUrl() lo descarta y cae a HUB_URL (correcto arriba). + - VITE_HUB_URL=https://workspace.aduanasoft.com + # Keycloak — fuente única de autenticación: Workspace (workspace.aduanasoft.com) + # VITE_KEYCLOAK_URL: URL pública que el browser usa para el flujo OIDC. + # Debe ser el mismo Keycloak donde el usuario tiene su sesión de Workspace. + - VITE_KEYCLOAK_URL=${VITE_KEYCLOAK_URL:-https://workspace.aduanasoft.com/kcauth} + - VITE_KEYCLOAK_REALM=${VITE_KEYCLOAK_REALM:-master} + - VITE_KEYCLOAK_CLIENT_ID=${VITE_KEYCLOAK_CLIENT_ID:-app-frontend} + # KEYCLOAK_URL: URL que usa SvelteKit server-side para intercambiar código por tokens. + - KEYCLOAK_URL=${KEYCLOAK_URL:-https://workspace.aduanasoft.com/kcauth} + - KEYCLOAK_REALM=${KEYCLOAK_REALM:-master} + - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-app-frontend} + - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-} + # SvelteKit ORIGIN — determina url.origin en load functions y el flag secure de cookies. + # ⚠️ Docker Compose carga el .env raíz automáticamente. Si ese .env tiene + # ORIGIN=http://localhost:5173 (valor dev), sobreescribe el default de abajo. + # SITE_URL es el fallback que usa el código cuando url.origin es localhost. + - ORIGIN=${ORIGIN:-https://anexo76-dev.aduanasoft.com} + # SITE_URL: hardcoded — no depende del .env raíz. + # resolveSystemBaseUrl() lo usa cuando ORIGIN tiene localhost (root .env de dev en prod). + - SITE_URL=https://anexo76-dev.aduanasoft.com + ports: + - "5111:5173" + depends_on: + backend: + condition: service_healthy + networks: + - frontend-net + - backend-net + restart: unless-stopped + command: [ "pnpm", "start" ] + healthcheck: + test: [ "CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:5173/ || exit 1" ] + interval: 15s + timeout: 5s + retries: 5 + start_period: 45s + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + deploy: + resources: + limits: + memory: 1G + reservations: + memory: 512M + +volumes: + postgres_app_data: + driver: local + frontend_node_modules: + driver: local + backend_cache: + driver: local + backend_uploads: + driver: local + backend_layouts: + driver: local + minio_data: + driver: local + +networks: + backend-net: + driver: bridge + frontend-net: + driver: bridge diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..234ce22 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,599 @@ +# Anexo76 - Resumen de Arquitectura Técnica + +## 📋 Índice +1. [Visión General](#visión-general) +2. [Stack Tecnológico](#stack-tecnológico) +3. [Arquitectura del Sistema](#arquitectura-del-sistema) +4. [Arquitectura de Schemas y Módulos](#arquitectura-de-schemas-y-módulos) +5. [Estructura del Proyecto](#estructura-del-proyecto) +6. [Flujos Principales](#flujos-principales) +7. [Seguridad](#seguridad) +8. [Base de Datos](#base-de-datos) +9. [API Reference](#api-reference) + +--- + +## Visión General + +Anexo76 es una aplicación SaaS multi-tenant para gestión de comercio exterior en México, enfocada en cumplir con los Anexos 24, 31 y 22 del SAT. + +### Objetivos de Negocio +- Gestión de inventarios para maquilas e IMMEX +- Control de pedimentos aduanales +- Manejo de facturas de importación/exportación +- Cumplimiento normativo SAT +- Licenciamiento flexible por planes + +--- + +## Stack Tecnológico + +### Backend +- **Framework**: FastAPI 0.110+ (Python 3.11+) +- **ORM**: SQLAlchemy 2.0 +- **Autenticación**: Keycloak (OpenID Connect) +- **Base de Datos**: PostgreSQL 15+ +- **Validación**: Pydantic 2.6+ +- **Testing**: Pytest + +### Frontend +- **Framework**: SvelteKit 2.0+ (Svelte 5) +- **Lenguaje**: TypeScript +- **Auth Client**: keycloak-js +- **Estilos**: TailwindCSS 4.1+ +- **Build**: Vite 7+ + +### Infraestructura +- **Containerización**: Docker / Docker Compose +- **Orquestación**: Kubernetes (futuro) +- **CI/CD**: GitHub Actions / GitLab CI +- **Monitoreo**: Prometheus + Grafana + +--- + +## Arquitectura del Sistema + +### Patrón Arquitectónico: Modular Layered (estilo NestJS) + +``` +┌─────────────────────────────────────────────────────────┐ +│ FRONTEND │ +│ SvelteKit + Keycloak-js + TailwindCSS │ +└────────────────┬────────────────────────────────────────┘ + │ HTTP/REST + JWT +┌────────────────▼────────────────────────────────────────┐ +│ API GATEWAY (FastAPI) │ +│ Middleware: Tenant | License | Logging | CORS │ +└────────────────┬────────────────────────────────────────┘ + │ + ┌────────┴────────┐ + │ │ +┌───────▼──────┐ ┌──────▼────────┐ +│ MODULES │ │ CORE LAYER │ +│ │ │ │ +│ • auth │ │ • config.py │ +│ • tenants │ │ • database.py │ +│ • licenses │ │ • security.py │ +│ • ... │ │ • middleware │ +└───────┬──────┘ └───────────────┘ + │ +┌───────▼──────────────────────────┐ +│ DATABASE LAYER (Multi-tenant) │ +│ │ +│ ┌──────────┐ ┌──────────────┐ │ +│ │ Core DB │ │ Tenant 1 DB │ │ +│ │ (shared) │ │ (dedicated) │ │ +│ └──────────┘ └──────────────┘ │ +└──────────────────────────────────┘ +``` + +### Estructura Modular (por módulo) + +Cada módulo sigue el patrón: + +``` +modules/{module_name}/ +├── models.py # ORM Models (SQLAlchemy) +├── dto.py # Data Transfer Objects (Pydantic) +├── service.py # Business Logic Layer +├── routes.py # API Endpoints (FastAPI) +└── __init__.py # Module exports +``` + +#### Responsabilidades por Capa + +1. **models.py**: Representación de entidades en BD + - Define tablas con SQLAlchemy + - Relaciones entre entidades + - Constraints y validaciones a nivel DB + +2. **dto.py**: Contratos de entrada/salida de datos + - DTOs de request (CreateDTO, UpdateDTO) + - DTOs de response (ResponseDTO) + - Validaciones de Pydantic + +3. **service.py**: Lógica de negocio + - Operaciones CRUD + - Validaciones de negocio + - Orquestación de operaciones complejas + +4. **routes.py**: Exposición HTTP + - Definición de endpoints + - Documentación OpenAPI automática + - Manejo de dependencias (auth, db) + +--- + +## Arquitectura de Schemas y Módulos + +### Estructura de Schemas en Base de Datos + +La aplicación utiliza una arquitectura de schemas para organizar lógicamente las tablas según su funcionalidad y alcance: + +#### **Schema `a24` (Anexo 24)** +Contiene todas las tablas relacionadas con el **Anexo 24 del SAT** (control de inventarios para empresas IMMEX): +- Gestión de inventarios +- Control de entradas y salidas de mercancías +- Reportes de existencias +- Cumplimiento de obligaciones fiscales del Anexo 24 + +#### **Schema `a76` (Anexo 76)** +Contiene todas las tablas relacionadas con el **Anexo 76 del SAT** (comercio exterior): +- Pedimentos aduanales +- Facturas de importación/exportación +- Documentación de comercio exterior +- Cumplimiento normativo de comercio exterior + +#### **Schema `public` (Catálogos Fijos)** +Contiene **catálogos compartidos** y datos de referencia que no cambian frecuentemente: +- Catálogos del SAT (tipos de material, unidades de medida, etc.) +- Códigos de país +- Catálogos de aduanas +- Tipos de documento +- Datos maestros compartidos entre módulos + +### Convención de Prefijos de Tablas + +Para mantener claridad y trazabilidad, las tablas utilizan prefijos que identifican su módulo funcional: + +#### **Prefijo `inv_` (Inventarios)** +Tablas relacionadas con el **control de inventarios**: +- `inv_products`: Productos en inventario +- `inv_movements`: Movimientos de entrada/salida +- `inv_warehouses`: Almacenes +- `inv_balances`: Saldos de inventario + +**Nota histórica**: Anteriormente se utilizaba el prefijo `s` (SCAII - Sistema de aduanas e Inventarios). + +#### **Prefijo `fa_` (Fixed Assets / Activos Fijos)** +Tablas relacionadas con la **gestión de activos fijos**: +- `fa_assets`: Registro de activos fijos +- `fa_depreciation`: Depreciación de activos +- `fa_maintenance`: Mantenimiento de activos +- `fa_transfers`: Transferencias de activos + +**Nota histórica**: Anteriormente se utilizaba el prefijo `q` (SCAF - Sistema de Control de Activos Fijos). + +### Diagrama de Arquitectura de Schemas + +``` +┌─────────────────────────────────────────────────────────────┐ +│ DATABASE: anexo76_db │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌────────────────┐ ┌────────────────┐ ┌───────────────┐ │ +│ │ Schema: a24 │ │ Schema: a76 │ │Schema: public │ │ +│ │ (Anexo 24) │ │ (Anexo 76) │ │ (Catálogos) │ │ +│ ├────────────────┤ ├────────────────┤ ├───────────────┤ │ +│ │ │ │ │ │ │ │ +│ │ inv_products │ │ pedimentos │ │ material_types│ │ +│ │ inv_movements │ │ facturas │ │ uom_codes │ │ +│ │ inv_warehouses │ │ customs_docs │ │ countries │ │ +│ │ inv_balances │ │ export_ops │ │ customs_list │ │ +│ │ │ │ │ │ document_types│ │ +│ │ fa_assets │ │ │ │ │ │ +│ │ fa_depreciation│ │ │ │ │ │ +│ │ fa_maintenance │ │ │ │ │ │ +│ │ fa_transfers │ │ │ │ │ │ +│ │ │ │ │ │ │ │ +│ └────────────────┘ └────────────────┘ └───────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Ventajas de esta Arquitectura + +1. **Separación Lógica**: Cada schema representa un dominio específico del negocio +2. **Escalabilidad**: Facilita la adición de nuevos módulos sin afectar los existentes +3. **Seguridad**: Permite aplicar permisos a nivel de schema +4. **Mantenibilidad**: Código y migraciones organizados por dominio +5. **Claridad**: Los prefijos hacen evidente la funcionalidad de cada tabla +6. **Migración Gradual**: Permite actualizar sistemas legados (SCAII/SCAF) sin interrupciones + +### Mapeo de Sistemas Legados + +| Sistema Legacy | Prefijo Antiguo | Sistema Nuevo | Prefijo Nuevo | Schema | +|----------------------|-----------------|-------------------|---------------|----------| +| SCAII (Inventarios) | `s` | Inventarios | `inv_` | `a24` | +| SCAF (Activos Fijos) | `q` | Fixed Assets | `fa_` | `a24` | +| Winsaii (Pedimentos) | `w` | - | - | `a22` | +| - | `g` | Comercio Exterior | - | `a76` | +| - | `g` | Catálogos SAT | - | `public` | + +--- + +## Estructura del Proyecto + +``` +anexo76/ +├── backend/ +│ ├── main.py # Aplicación FastAPI principal +│ ├── requirements.txt # Dependencias +│ ├── init_db.py # Script de inicialización +│ ├── Dockerfile +│ │ +│ ├── core/ # Capa core (shared) +│ │ ├── config.py # Configuración (Pydantic Settings) +│ │ ├── database.py # Gestión de BD multi-tenant +│ │ ├── security.py # Auth Keycloak + JWT +│ │ ├── middleware.py # Middlewares personalizados +│ │ └── __init__.py +│ │ +│ └── api/ +│ └── v1/ +│ ├── router.py # Router principal v1 +│ ├── common/ # Utilidades compartidas +│ │ ├── base_models.py +│ │ ├── crud_routes.py +│ │ ├── dto_mixins.py +│ │ └── tenant_crud_routes.py +│ │ +│ └── modules/ # Módulos de negocio por schema +│ ├── a24/ # Módulo Anexo 24 (Inventarios) +│ │ ├── inventarios/ +│ │ └── activos_fijos/ +│ │ +│ ├── a76/ # Módulo Anexo 76 (Comercio Exterior) +│ │ ├── pedimentos/ +│ │ └── facturas/ +│ │ +│ └── public/ # Catálogos compartidos +│ ├── material_types/ +│ ├── uom_codes/ +│ └── countries/ +│ +├── frontend/ +│ ├── src/ +│ │ ├── routes/ # Páginas SvelteKit +│ │ │ ├── +layout.svelte # Layout global con Keycloak +│ │ │ ├── +page.svelte # Dashboard principal +│ │ │ └── callback/ # OAuth callback +│ │ │ +│ │ └── lib/ +│ │ ├── auth.ts # Servicio de autenticación +│ │ └── api.ts # Cliente API +│ │ +│ ├── static/ +│ │ └── silent-check-sso.html +│ ├── package.json +│ └── Dockerfile +│ +├── docs/ +│ ├── KEYCLOAK_SETUP.md # Guía de configuración +│ └── ARCHITECTURE.md # Este documento +│ +├── docker-compose.yml # Orquestación completa +├── start.sh # Script de inicio rápido +├── README.md # Documentación principal +└── .gitignore +``` + +--- + +## Flujos Principales + +### 1. Flujo de Autenticación + +``` +┌──────────┐ ┌──────────┐ ┌──────────┐ +│ Frontend │ │ Keycloak │ │ Backend │ +└────┬─────┘ └────┬─────┘ └────┬─────┘ + │ │ │ + │ 1. Clic "Login" │ │ + ├────────────────────────────>│ │ + │ │ │ + │ 2. Formulario de login │ │ + │<────────────────────────────┤ │ + │ │ │ + │ 3. Credenciales │ │ + ├────────────────────────────>│ │ + │ │ │ + │ 4. Redirigir + auth code │ │ + │<────────────────────────────┤ │ + │ │ │ + │ 5. Intercambiar code x token│ │ + ├────────────────────────────>│ │ + │ │ │ + │ 6. JWT (access + refresh) │ │ + │<────────────────────────────┤ │ + │ │ │ + │ 7. Request con Bearer token │ │ + ├─────────────────────────────┼──────────────────────────>│ + │ │ │ + │ │ 8. Validar token │ + │ │<──────────────────────────┤ + │ │ │ + │ │ 9. Public key │ + │ ├──────────────────────────>│ + │ │ │ + │ 10. Respuesta con datos │ │ + │<─────────────────────────────┼───────────────────────────┤ + │ │ │ +``` + +### 2. Flujo de Request Multi-tenant + +``` +Request con JWT + ↓ +TenantMiddleware +├─ Extrae tenant_id del token +├─ Valida tenant existe y está activo +└─ Agrega tenant_id a request.state + ↓ +LicenseValidationMiddleware +├─ Consulta licencia del tenant +├─ Valida estado (active/expired) +├─ Valida fecha de vigencia +└─ Agrega license_info a request.state + ↓ +Endpoint Handler +├─ Obtiene tenant_id de request.state +├─ Selecciona BD (shared o dedicated) +└─ Procesa request + ↓ +Response +``` + +### 3. Flujo de Selección de Base de Datos + +```python +# Pseudocódigo +tenant_id = request.state.tenant_id + +tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first() + +if tenant.type == "SHARED": + # Usar BD compartida (core_db) + db_session = CoreSessionLocal() + # Queries incluyen tenant_id en WHERE + +elif tenant.type == "DEDICATED": + # Usar BD dedicada del tenant + db_config = json.loads(tenant.db_config) + db_session = get_tenant_db(tenant_id, db_config) + # No necesita filtrar por tenant_id +``` + +--- + +## Seguridad + +### Autenticación +- **Keycloak** como Identity Provider +- **OpenID Connect** (OIDC) +- **JWT** con RS256 (firma asimétrica) +- **Refresh tokens** para renovación + +### Autorización +- **RBAC** (Role-Based Access Control) +- Roles: `admin`, `user`, `auditor`, `system` +- Middleware `has_role()` para proteger endpoints + +### Multi-tenancy +- **Aislamiento por tenant_id** en JWT +- **Row-level security** en BD compartida +- **BD dedicada** para mayor aislamiento (enterprise) + +### Row-Level Security (RLS) en BD compartida + +> Convención alineada al skill `aduanasoft-dev-standards` (sección 10). +> La capa API sigue siendo responsable del control fino (roles/permisos +> con Keycloak + `PermissionService`); RLS añade **defensa en profundidad** +> a nivel de BD para que un bug en un `WHERE` no permita salirse del tenant. + +#### Variables de sesión (`SET LOCAL`) + +| GUC | Origen | Comportamiento RLS | +|-----|--------|--------------------| +| `app.tenant_id` | JWT (`TenantMiddleware`) → `request.state.tenant_id` | Obligatoria. Si está vacía, `app.current_tenant_id()` retorna `NULL` y las políticas devuelven `0` filas (fail-closed). | +| `app.company_id` | Header `X-Company-Id` o cookie `active_company_id` | Opcional. Si está vacía, el tenant ve **todas sus compañías** (útil para selectores de compañía y bootstrap). | + +Ambas se fijan con `SET LOCAL` al inicio de cada transacción — +**nunca** con `SET` global, para no contaminar conexiones del pool. + +Helpers SQL definidos por la migración `d1a2b3c4e5f6_enable_rls_tenant_company`: + +```sql +CREATE FUNCTION app.current_tenant_id() RETURNS INTEGER LANGUAGE sql STABLE AS +$$ SELECT NULLIF(current_setting('app.tenant_id', true), '')::INTEGER $$; +CREATE FUNCTION app.current_company_id() RETURNS INTEGER LANGUAGE sql STABLE AS +$$ SELECT NULLIF(current_setting('app.company_id', true), '')::INTEGER $$; +``` + +#### Tipos de política + +1. **Solo `tenant_id`** (p.ej. `a76.company`, `core.licenses`): + `tenant_id = app.current_tenant_id()`. +2. **`tenant_id` + `company_id`** (`TenantScopedMixin`, mayoría de tablas + `a24/`a76/`core`): además exige `company_id = app.current_company_id()` + cuando esa GUC está fijada. +3. **Solo `company_id`** (algunas tablas `a76.company_*`): valida el + `tenant_id` indirectamente vía `EXISTS` contra `a76.company`. + +Todas las tablas usan `FORCE ROW LEVEL SECURITY` para que la política +aplique también al owner. Las únicas tablas core **excluidas** son +`core.tenants` y `core.user_tenants` — necesarias para el bootstrap del +selector de tenant antes de tener contexto fijado. + +#### Propagación del contexto + +| Camino | Cómo se fija el contexto | +|--------|--------------------------| +| HTTP request | `TenantMiddleware` rellena `request.state.tenant_id`/`company_id`; `get_core_db` / `get_async_core_db` leen esos valores y los guardan en `Session.info`. Un listener `after_begin` ejecuta `SET LOCAL` por transacción. | +| `LicenseValidationMiddleware` | Usa `scoped_core_db(tenant_id=...)` para que la consulta de licencia entre con contexto RLS válido. | +| Tareas Celery | `track_and_dispatch` inyecta `rls_tenant_id` / `rls_company_id` en los headers del task; los signals `task_prerun`/`task_postrun` los copian a `ContextVar`s del worker, que el listener `after_begin` consume como fallback. Tareas críticas (imports/exports de invoices, expediente) abren la sesión con `scoped_core_db(tenant_id=..., company_id=...)`. | +| Tests | Las suites de pytest pueden usar `scoped_core_db(...)` o emular el flujo con `set_config('app.tenant_id', ...)` antes del query. Hay un set de tests en `backend/tests/integration/test_rls_tenant_company.py` que valida aislamiento A vs B usando un rol sin `BYPASSRLS`. | + +#### Reparto de responsabilidades + +| Capa | Decide | +|------|--------| +| **API (FastAPI + Keycloak + `PermissionService`)** | Roles, permisos por compañía, accesos a recursos concretos (`validate_access_to_resource`), reglas de negocio. | +| **RLS (PostgreSQL)** | Límite estructural duro: `tenant_id` y `company_id`. **No** modela roles/permisos para evitar duplicar lógica fina con la API. | + +#### Operación / DevOps + +- En **producción** la API debe conectar con un rol **sin** `BYPASSRLS` + (`postgres` superusuario lo bypassea por diseño). El `docker-compose.yml` + de desarrollo usa `postgres` deliberadamente para no romper migraciones; + los tests crean un rol `anexo76_rls_test` para ejercitar las políticas. +- Los jobs/ETL/migraciones que necesiten ver todos los tenants deben usar + un rol técnico explícito con `BYPASSRLS` o fijar `app.tenant_id` por + iteración — nunca asumir que la sesión global "ve todo". +- La migración `d1a2b3c4e5f6_enable_rls_tenant_company` tiene `downgrade()` + completo (drop policies + `DISABLE ROW LEVEL SECURITY`) para revertir. + +### Validación de Licencias +- Middleware verifica en cada request: + - ✓ Licencia activa + - ✓ No expirada + - ✓ Límites no excedidos + +--- + +## Base de Datos + +### Modelo Híbrido Multi-tenant + +#### BD Core (Compartida) +Tablas principales: +- `tenants`: Información de clientes +- `licenses`: Control de licencias por tenant +- `license_usage`: Métricas de uso +- `users` (futuro): Usuarios por tenant + +Todas las tablas operacionales incluyen `tenant_id` para segmentación. + +#### BD Dedicadas (Enterprise) +- Una BD PostgreSQL por tenant +- Configuración almacenada en `tenants.db_config` +- Migración automática desde BD compartida + +### Ejemplo de Tabla Multi-tenant + +```sql +CREATE TABLE inventories ( + id SERIAL PRIMARY KEY, + tenant_id INTEGER NOT NULL REFERENCES tenants(id), + product_code VARCHAR(50) NOT NULL, + quantity INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT NOW(), + + -- Índice compuesto para queries eficientes + INDEX idx_tenant_product (tenant_id, product_code) +); +``` + +### Migración y Upgrade + +```python +# Tenant en BD compartida → BD dedicada +tenant_service.upgrade_to_dedicated( + tenant_id=123, + db_config={ + "host": "dedicated-postgres.example.com", + "port": 5432, + "name": "tenant_123_db", + "user": "tenant_123_user", + "password": "secure_password" + } +) +``` + +--- + +## API Reference + +### Módulo: Authentication (`/v1/auth`) + +| Endpoint | Método | Descripción | Auth | +|----------|--------|-------------|------| +| `/auth/login` | POST | Login con Keycloak | Público | +| `/auth/refresh` | POST | Renovar access token | Público | +| `/auth/me` | GET | Info del usuario actual | Bearer | +| `/auth/logout` | POST | Cerrar sesión | Bearer | +| `/auth/health` | GET | Health check | Público | + +### Módulo: Tenants (`/v1/tenants`) + +| Endpoint | Método | Descripción | Rol Requerido | +|----------|--------|-------------|---------------| +| `/tenants` | POST | Crear tenant | admin | +| `/tenants` | GET | Listar tenants | admin | +| `/tenants/{id}` | GET | Obtener tenant | user | +| `/tenants/{id}` | PUT | Actualizar tenant | admin | +| `/tenants/{id}` | DELETE | Eliminar tenant | admin | +| `/tenants/slug/{slug}` | GET | Obtener por slug | user | + +### Módulo: Licenses (`/v1/licenses`) + +| Endpoint | Método | Descripción | Rol Requerido | +|----------|--------|-------------|---------------| +| `/licenses` | POST | Crear licencia | admin | +| `/licenses/tenant/{id}` | GET | Obtener licencia | user | +| `/licenses/tenant/{id}` | PUT | Actualizar licencia | admin | +| `/licenses/validate/{id}` | GET | Validar licencia | user | +| `/licenses/usage/{id}` | GET | Uso de licencia | user | +| `/licenses/my-license` | GET | Mi licencia | user | + +### Planes de Licencia + +| Plan | Usuarios | Storage | Operaciones/mes | Features | +|------|----------|---------|-----------------|----------| +| Free | 5 | 10 GB | 1,000 | API básica | +| Basic | 20 | 50 GB | 10,000 | + Reportes | +| Professional | 100 | 200 GB | 50,000 | + Integraciones | +| Enterprise | ∞ | ∞ | ∞ | + Soporte + BD dedicada | + +--- + +## Próximas Implementaciones + +### Backend +- [ ] Módulo de inventarios +- [ ] Módulo de pedimentos +- [ ] Módulo de facturas +- [ ] Webhooks para integraciones +- [ ] Reportes avanzados +- [ ] Export/Import de datos + +### Frontend +- [ ] Dashboard con gráficas +- [ ] Gestión de inventarios UI +- [ ] Formularios de pedimentos +- [ ] Panel de administración +- [ ] Reportes interactivos + +### DevOps +- [ ] CI/CD pipeline +- [ ] Tests automatizados +- [ ] Monitoreo con Prometheus +- [ ] Dashboards de Grafana +- [ ] Deploy a Kubernetes +- [ ] Backup automatizado + +--- + +**Última actualización**: Octubre 2025 +**Versión del documento**: 1.0 diff --git a/docs/KEYCLOAK_SETUP.md b/docs/KEYCLOAK_SETUP.md new file mode 100644 index 0000000..d08d745 --- /dev/null +++ b/docs/KEYCLOAK_SETUP.md @@ -0,0 +1,227 @@ +# Guía de Configuración de Keycloak para Anexo76 + +Esta guía te ayudará a configurar Keycloak para usar con Anexo76. + +# Script auto initialize + +Te genera toda la configruracion inicial de keycloack que se ve en este documento, +aparte de esto te genera un primer usuario configurado con su tenant y una company + +``` +scripts/init_first_time.sh +``` + +## 1. Acceder a Keycloak Admin Console + +1. Abrir http://localhost:8080 +2. Hacer clic en "Administration Console" +3. Login con: `admin` / `admin` + +## 2. Configurar Cliente Backend + +### Crear Cliente Backend + +1. En el menú izquierdo, ir a **Clients** +2. Clic en **Create client** +3. Configurar: + - **Client ID**: `anexo76-backend` + - **Client Protocol**: `openid-connect` + - Clic en **Next** +4. En la siguiente pantalla: + - **Client authentication**: ON (Confidential) + - **Authorization**: OFF + - **Authentication flow**: Marcar solo "Standard flow" y "Direct access grants" + - Clic en **Next** +5. En "Login settings": + - **Root URL**: `http://localhost:8000` + - **Valid redirect URIs**: `http://localhost:8000/*` + - **Web origins**: `http://localhost:8000` + - Clic en **Save** + +### Obtener Client Secret + +1. Ir a la pestaña **Credentials** +2. Copiar el **Client secret** +3. Agregar al archivo `backend/.env`: + ``` + KEYCLOAK_CLIENT_SECRET=tu-client-secret-aqui + ``` + +## 3. Configurar Cliente Frontend + +### Crear Cliente Frontend + +1. En **Clients**, clic en **Create client** +2. Configurar: + - **Client ID**: `anexo76-frontend` + - **Client Protocol**: `openid-connect` + - Clic en **Next** +3. En la siguiente pantalla: + - **Client authentication**: OFF (Public) + - **Authorization**: OFF + - **Authentication flow**: Marcar "Standard flow" + - Clic en **Next** +4. En "Login settings": + - **Root URL**: `http://localhost:5173` + - **Valid redirect URIs**: + - `http://localhost:5173/*` + - `http://localhost:3000/*` + - **Valid post logout redirect URIs**: + - `http://localhost:5173/*` + - `http://localhost:3000/*` + - **Web origins**: + - `http://localhost:5173` + - `http://localhost:3000` + - Clic en **Save** + +## 4. Crear Usuario de Prueba + +### Crear Usuario + +1. En el menú izquierdo, ir a **Users** +2. Clic en **Add user** +3. Configurar: + - **Username**: `demo` + - **Email**: `demo@empresa-demo.com` + - **First name**: `Usuario` + - **Last name**: `Demo` + - **Email verified**: ON + - Clic en **Create** + +### Establecer Contraseña + +1. Ir a la pestaña **Credentials** +2. Clic en **Set password** +3. Configurar: + - **Password**: `demo123` + - **Password confirmation**: `demo123` + - **Temporary**: OFF (para no tener que cambiar la contraseña) +4. Clic en **Save** + +### Agregar Atributo tenant_id + +1. En el mismo usuario, ir a la pestaña **Attributes** +2. Clic en **Add an attribute** +3. Configurar: + - **Key**: `tenant_id` + - **Value**: `1` +4. Clic en **Save** + +### Asignar Roles + +1. Ir a la pestaña **Role mappings** +2. En "Available roles", buscar y asignar: + - `admin` (si existe) + - `user` (si existe) +3. Si no existen estos roles, crearlos primero: + - Ir a **Realm roles** en el menú izquierdo + - Crear roles: `admin`, `user`, `auditor`, `system` + - Regresar al usuario y asignar roles + +## 5. Configurar Mapper para tenant_id (Opcional pero recomendado) + +Para que el `tenant_id` se incluya automáticamente en el token: + +1. Ir a **Clients** → `anexo76-backend` +2. Ir a la pestaña **Client scopes** +3. Clic en `anexo76-backend-dedicated` +4. Ir a la pestaña **Mappers** +5. Clic en **Add mapper** → **By configuration** → **User Attribute** +6. Configurar: + - **Name**: `tenant-id-mapper` + - **User Attribute**: `tenant_id` + - **Token Claim Name**: `tenant_id` + - **Claim JSON Type**: `String` + - **Add to ID token**: ON + - **Add to access token**: ON + - **Add to userinfo**: ON +7. Clic en **Save** + +Repetir para el cliente `anexo76-frontend` si es necesario. + +## 6. Verificar Configuración + +### Probar desde el Frontend + +1. Abrir http://localhost:5173 +2. Hacer clic en "Iniciar Sesión" +3. Ingresar credenciales: + - Usuario: `demo` + - Contraseña: `demo123` +4. Deberías ver el dashboard con información del usuario y licencia + +### Probar desde el API + +```bash +# Obtener token +curl -X POST http://localhost:8080/realms/master/protocol/openid-connect/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "client_id=anexo76-backend" \ + -d "client_secret=TU_CLIENT_SECRET" \ + -d "username=demo" \ + -d "password=demo123" \ + -d "grant_type=password" + +# Usar el token para llamar al API +curl -X GET http://localhost:8000/v1/auth/me \ + -H "Authorization: Bearer TU_ACCESS_TOKEN" +``` + +## 7. Configuración Adicional (Opcional) + +### Personalizar Tema de Login + +1. Ir a **Realm settings** → **Themes** +2. Seleccionar tema de login deseado +3. Guardar cambios + +### Configurar Timeout de Sesión + +1. Ir a **Realm settings** → **Sessions** +2. Ajustar: + - **SSO Session Idle**: Tiempo de inactividad antes de expirar (ej: 30 minutos) + - **SSO Session Max**: Tiempo máximo de sesión (ej: 10 horas) +3. Guardar cambios + +### Habilitar Registro de Usuarios (Opcional) + +1. Ir a **Realm settings** → **Login** +2. Activar **User registration** +3. Guardar cambios + +## Troubleshooting + +### Error: "Invalid redirect URI" + +- Verificar que las URIs en el cliente coincidan exactamente +- Incluir el protocolo (http:// o https://) +- Incluir el puerto si es necesario + +### Error: "Client not found" + +- Verificar que el Client ID sea exacto +- Verificar que el realm sea correcto + +### Token no incluye tenant_id + +- Verificar que el usuario tenga el atributo configurado +- Verificar que el mapper esté configurado correctamente +- Probar obteniendo un nuevo token + +### Usuario no puede hacer login + +- Verificar que el usuario esté habilitado (User enabled: ON) +- Verificar que el email esté verificado (Email verified: ON) +- Verificar que la contraseña no sea temporal + +## Próximos Pasos + +1. Para producción, cambiar el realm de `master` a uno dedicado +2. Configurar HTTPS/TLS en Keycloak +3. Configurar backup de la base de datos de Keycloak +4. Implementar políticas de contraseña más estrictas +5. Configurar MFA (Multi-Factor Authentication) + +--- + +**¡Listo!** Tu configuración de Keycloak está completa para desarrollo. diff --git a/docs/MICROSOFT_SSO_SETUP.md b/docs/MICROSOFT_SSO_SETUP.md new file mode 100644 index 0000000..fad1284 --- /dev/null +++ b/docs/MICROSOFT_SSO_SETUP.md @@ -0,0 +1,214 @@ +# Configuración de Login con Microsoft (Azure AD) + +Esta guía te ayudará a configurar el login con Microsoft junto con el login tradicional. + +## Parte 1: Configurar Aplicación en Azure AD + +### 1.1 Crear App Registration en Azure Portal + +1. Ve a [Azure Portal](https://portal.azure.com) +2. Busca "Azure Active Directory" o "Microsoft Entra ID" +3. En el menú lateral, selecciona **App registrations** +4. Clic en **New registration** +5. Configura: + - **Name**: `Anexo76` + - **Supported account types**: + - "Accounts in any organizational directory (Any Azure AD directory - Multitenant)" + - O "Accounts in any organizational directory and personal Microsoft accounts" si quieres permitir cuentas @outlook.com, @hotmail.com + - **Redirect URI**: + - Platform: `Web` + - URI: `http://localhost:8080/realms/master/broker/microsoft/endpoint` + - Clic en **Register** + +### 1.2 Obtener Client ID y crear Client Secret + +1. En la página de tu aplicación, copia el **Application (client) ID** +2. Ve a **Certificates & secrets** en el menú lateral +3. Clic en **New client secret** +4. Descripción: `keycloak-integration` +5. Expires: Selecciona el tiempo que prefieras (ej: 24 months) +6. Clic en **Add** +7. **IMPORTANTE**: Copia el **Value** del secret inmediatamente (solo se muestra una vez) + +### 1.3 Configurar API Permissions (Opcional pero recomendado) + +1. Ve a **API permissions** +2. Deberías ver `Microsoft Graph` > `User.Read` (Delegated) - esto es suficiente +3. Si quieres más información del usuario, agrega: + - `email` + - `profile` + - `openid` + +## Parte 2: Configurar Identity Provider en Keycloak + +### 2.1 Agregar Microsoft como Identity Provider + +1. Abre Keycloak Admin Console: http://localhost:8080 +2. Login como admin +3. Asegúrate de estar en el realm correcto (probablemente `master`) +4. En el menú lateral, ve a **Identity providers** +5. En el dropdown "Add provider", selecciona **Microsoft** +6. Configura: + - **Alias**: `microsoft` (o cualquier nombre que prefieras) + - **Display name**: `Microsoft` (esto es lo que verá el usuario) + - **Enabled**: ON + - **Store tokens**: ON (opcional, para poder usar tokens de Microsoft después) + - **Stored tokens readable**: OFF + - **Trust email**: ON + - **First login flow**: `first broker login` + - **Client ID**: Pega el Application (client) ID de Azure + - **Client Secret**: Pega el client secret que copiaste + - Clic en **Save** + +### 2.2 Configurar Mappers (Mapeo de atributos) + +Después de guardar, configura los mappers para traer información del usuario de Microsoft: + +1. En la misma página del Identity Provider, ve a la pestaña **Mappers** +2. Clic en **Add mapper** + +**Mapper 1: Email** +- Name: `email` +- Sync mode override: `inherit` +- Mapper type: `Attribute Importer` +- Social profile JSON field path: `email` +- User attribute name: `email` +- Clic en **Save** + +**Mapper 2: First Name** +- Name: `firstName` +- Mapper type: `Attribute Importer` +- Social profile JSON field path: `given_name` +- User attribute name: `firstName` +- Clic en **Save** + +**Mapper 3: Last Name** +- Name: `lastName` +- Mapper type: `Attribute Importer` +- Social profile JSON field path: `family_name` +- User attribute name: `lastName` +- Clic en **Save** + +**Mapper 4: Username** +- Name: `username` +- Mapper type: `Username Template Importer` +- Template: `${CLAIM.email}` +- Target: `BROKER_USERNAME` +- Clic en **Save** + +### 2.3 Configurar Redirect URI en Azure (si es necesario) + +Si usas un realm diferente a `master`, actualiza la Redirect URI en Azure: + +- Formato: `http://localhost:8080/realms/{REALM_NAME}/broker/microsoft/endpoint` +- Para producción: `https://tu-dominio.com/realms/{REALM_NAME}/broker/microsoft/endpoint` + +## Parte 3: Actualizar Frontend + +El frontend necesita detectar y mostrar el botón de Microsoft. Keycloak proporciona esta información automáticamente. + +### 3.1 Obtener Identity Providers disponibles + +Tu frontend puede consultar los Identity Providers disponibles: + +**Endpoint de Keycloak:** +``` +GET http://localhost:8080/realms/master/broker-login/identity-providers +``` + +Esto retorna algo como: +```json +[ + { + "alias": "microsoft", + "displayName": "Microsoft", + "providerId": "microsoft", + "enabled": true + } +] +``` + +### 3.2 URL para iniciar flujo de Microsoft + +Para iniciar el login con Microsoft, redirige al usuario a: +``` +http://localhost:8080/realms/master/broker/microsoft/login?client_id=anexo76-frontend&redirect_uri=http://localhost:5173/auth/callback +``` + +Parámetros: +- `client_id`: Tu client ID de frontend en Keycloak (`anexo76-frontend`) +- `redirect_uri`: URL a la que Keycloak redirigirá después del login exitoso +- `response_type`: `code` (para authorization code flow) +- `scope`: `openid profile email` + +### 3.3 Manejar el Callback + +Después del login con Microsoft, Keycloak redirige a tu `redirect_uri` con un `code`: +``` +http://localhost:5173/auth/callback?code=abc123...&session_state=xyz... +``` + +Tu frontend debe: +1. Extraer el `code` del query string +2. Intercambiar el `code` por tokens llamando a tu backend +3. Tu backend llama a Keycloak para obtener los tokens + +## Parte 4: Testing + +### 4.1 Verificar que Microsoft aparece en la página de login + +Ve a: +``` +http://localhost:8080/realms/master/protocol/openid-connect/auth?client_id=anexo76-frontend&redirect_uri=http://localhost:5173&response_type=code +``` + +Deberías ver: +- Formulario de login tradicional (usuario/contraseña) +- Botón o link de "Microsoft" para login social + +### 4.2 Probar el flujo completo + +1. Haz clic en el botón de Microsoft +2. Serás redirigido a Microsoft login +3. Ingresa credenciales de Microsoft +4. Microsoft redirige a Keycloak +5. Keycloak crea/actualiza el usuario y redirige a tu app +6. Tu app obtiene el token y autentica al usuario + +## Notas Importantes + +### Multi-tenant con Microsoft + +Si tu app es multi-tenant y quieres que cada tenant use su propio Azure AD: +1. Crea múltiples Identity Providers en Keycloak (uno por tenant) +2. Usa aliases diferentes: `microsoft-tenant1`, `microsoft-tenant2` +3. En el frontend, muestra el botón correcto según el tenant + +### Asignación automática de tenant + +Cuando un usuario se loguea por primera vez con Microsoft, puedes: +1. Usar un mapper para asignar atributos basados en el dominio del email +2. Configurar "Default Tenant" en tu backend si el email es de un dominio conocido +3. Solicitar al usuario que seleccione su tenant en el primer login + +### Producción + +Para producción, recuerda: +1. Actualizar las Redirect URIs en Azure con tu dominio real +2. Usar HTTPS +3. Configurar correctamente los Web Origins en Keycloak +4. Usar variables de entorno para las configuraciones + +## Troubleshooting + +### Error: redirect_uri_mismatch +- Verifica que la URI en Azure coincida exactamente con la de Keycloak +- Formato: `https://tu-dominio.com/realms/{realm}/broker/{alias}/endpoint` + +### Usuario se crea pero no tiene tenant_id +- Configura un mapper en Keycloak para asignar tenant_id automáticamente +- O maneja esto en tu backend en el primer login + +### No aparece el botón de Microsoft +- Verifica que el Identity Provider esté habilitado en Keycloak +- Revisa que el Display Name esté configurado diff --git a/docs/VERIFICAR_MICROSOFT_CONFIG.md b/docs/VERIFICAR_MICROSOFT_CONFIG.md new file mode 100644 index 0000000..14a4395 --- /dev/null +++ b/docs/VERIFICAR_MICROSOFT_CONFIG.md @@ -0,0 +1,218 @@ +# ✅ Verificar Configuración de Microsoft en Keycloak + +## Paso 1: Verificar si Microsoft está configurado + +Abre tu navegador y ve a: + +``` +http://localhost:8080/admin/master/console/#/master/identity-providers +``` + +Login: `admin` / `admin` + +### ¿Qué deberías ver? + +Si Microsoft está configurado, verás en la lista de Identity Providers: + +- ✅ **microsoft** (o el alias que hayas usado) +- Con estado: **Enabled** ✅ + +### Si NO ves "microsoft" en la lista: + +**¡Necesitas configurarlo!** Sigue estos pasos: + +--- + +## Paso 2: Configurar Microsoft en Keycloak (SI NO ESTÁ CONFIGURADO) + +### 2.1 Crear App en Azure AD PRIMERO + +Antes de configurar Keycloak, necesitas una aplicación en Azure: + +1. Ve a [Azure Portal](https://portal.azure.com) +2. Busca **Azure Active Directory** o **Microsoft Entra ID** +3. **App registrations** → **New registration** +4. Configura: + + - **Name**: `Anexo76` + - **Supported account types**: `Accounts in any organizational directory (Any Azure AD - Multitenant)` + - **Redirect URI**: + - Platform: `Web` + - URI: `http://localhost:8080/realms/master/broker/microsoft/endpoint` + - Click **Register** +5. **Copia el Application (client) ID** - lo necesitarás +6. Ve a **Certificates & secrets** → **New client secret** + + - Descripción: `keycloak` + - Expira: 24 meses + - Click **Add** + - **¡COPIA EL SECRET VALUE AHORA!** (solo se muestra una vez) + +### 2.2 Agregar Microsoft a Keycloak + +1. En Keycloak Admin Console: http://localhost:8080 +2. Login: `admin` / `admin` +3. Menú lateral: **Identity providers** +4. Dropdown: **Add provider** → Selecciona **Microsoft** +5. Configura: + +``` +Alias: microsoft +Display name: Microsoft +Enabled: ON ✅ +Store tokens: ON ✅ +Trust email: ON ✅ +First login flow: first broker login + +Client ID: [PEGA TU APPLICATION ID DE AZURE] +Client Secret: [PEGA TU SECRET DE AZURE] +``` + +6. Click **Save** +7. Ve a la pestaña **Mappers** y agrega estos 4 mappers: + +**Mapper 1: email** + +``` +Name: email +Mapper type: Attribute Importer +Social profile JSON field path: email +User attribute name: email +``` + +**Mapper 2: firstName** + +``` +Name: firstName +Mapper type: Attribute Importer +Social profile JSON field path: given_name +User attribute name: firstName +``` + +**Mapper 3: lastName** + +``` +Name: lastName +Mapper type: Attribute Importer +Social profile JSON field path: family_name +User attribute name: lastName +``` + +**Mapper 4: username** + +``` +Name: username +Mapper type: Username Template Importer +Template: ${CLAIM.email} +Target: BROKER_USERNAME +``` + +--- + +## Paso 4: Verificar en tu App + +1. Asegúrate de que el frontend esté corriendo: `http://localhost:5173` +2. Ve a la página de login: `http://localhost:5173/login` +3. Ingresa un tenant (ej: `aduanasoft`) +4. Click en el botón **"Iniciar sesión con Microsoft"** + +### ¿Qué debería pasar? + +✅ **Correcto:** + +- Te redirige a Microsoft login +- Ves la página de Microsoft pidiendo tu email/contraseña +- Después de autenticarte, vuelves a tu app + +❌ **Incorrecto (lo que te está pasando ahora):** + +- Te lleva a la página de login de Keycloak +- Ves el usuario "admin" ya logueado + +--- + +## Troubleshooting Común + +### Error: "Identity provider not found" + +- El alias en Keycloak debe ser exactamente `microsoft` (minúsculas) +- O cambia el código: `loginWithProvider('TU_ALIAS_EXACTO')` + +### Error: redirect_uri_mismatch en Azure + +- La URI en Azure debe ser EXACTAMENTE: + ``` + http://localhost:8080/realms/master/broker/microsoft/endpoint + ``` +- Nota el `/endpoint` al final + +### Error: Unexpected error when authenticating with identity provider + +```bash +docker cp azure.crt anexo76-keycloak:/ +docker exec -it -u root anexo76-keycloak /bin/bash + +keytool -importcert -trustcacerts -file /azure.crt \ +-keystore /etc/java/java-21-openjdk/java-21-openjdk-21.0.8.0.9-1.el9.x86_64/lib/security/cacerts \ +-alias azure-root -storepass changeit -noprompt + +``` + +### Me redirige pero muestra error en Microsoft + +- Verifica que el Client ID y Secret en Keycloak sean correctos +- Verifica que la app en Azure esté habilitada + +### Funciona pero el usuario no tiene tenant_id + +- Esto es normal en el primer login +- Puedes configurar un mapper adicional o manejarlo en tu backend + +--- + +## Comando Rápido de Verificación + +Ejecuta esto en una terminal: + +```bash +# Verificar si el endpoint del broker existe +curl -s -o /dev/null -w "%{http_code}" "http://localhost:8080/realms/master/broker/microsoft/login?client_id=test&redirect_uri=http://localhost" +``` + +**Resultados:** + +- `302` = ✅ Microsoft está configurado (redirige a Microsoft) +- `404` = ❌ Microsoft NO está configurado en Keycloak +- `500` = ⚠️ Hay un error de configuración + +--- + +## Resumen Rápido + +**Para que funcione necesitas:** + +1. ✅ App Registration en Azure AD con Client ID y Secret +2. ✅ Identity Provider "microsoft" configurado en Keycloak +3. ✅ Redirect URI en Azure: `http://localhost:8080/realms/master/broker/microsoft/endpoint` +4. ✅ Variables de entorno en frontend (.env): + ``` + VITE_KEYCLOAK_URL=http://localhost:8080 + VITE_KEYCLOAK_REALM=master + VITE_KEYCLOAK_CLIENT_ID=anexo76-frontend + ``` + +**El flujo correcto es:** + +``` +Tu App → Keycloak Broker → Microsoft Login → Keycloak → Tu App +``` + +**Lo que está pasando ahora:** + +``` +Tu App → Keycloak Login (porque no encuentra el provider) +``` + +--- + +¿Necesitas ayuda con la configuración? Primero verifica en Keycloak Admin Console si existe el Identity Provider "microsoft". diff --git a/docs/a76.json b/docs/a76.json new file mode 100644 index 0000000..9d62e2b --- /dev/null +++ b/docs/a76.json @@ -0,0 +1,70 @@ +{ + "context": { + "project_name": "Anexo76", + "description": "Aplicación SaaS para gestión de comercio exterior conforme a Anexos 24, 30 y 22 del SAT.", + "business_goal": "Ofrecer una plataforma multi-tenant para maquilas, IMMEX y agentes aduanales que permita manejar inventarios, pedimentos y facturas de importación/exportación con control de licencias y cumplimiento normativo." + }, + "architecture": { + "frontend": { + "framework": "SvelteKit", + "auth_integration": "keycloak-js", + "ui_goal": "Dashboard moderno, responsivo y rápido para usuarios empresariales." + }, + "backend": { + "framework": "FastAPI", + "auth": "Keycloak (OpenID Connect)", + "db_model": "Hybrid multi-tenant", + "shared_db": "Base de datos central para clientes pequeños y medianos", + "dedicated_db": "Bases de datos independientes para clientes grandes o con alta operación", + "features": [ + "Conexión dinámica a BD según tenant", + "Middleware para validar licencias y tenants", + "APIs RESTful versionadas (v1, v2...)", + "Separación de capas: models (ORM), dto (Pydantic), service y routes" + ], + "module_structure": { + "pattern": "backend/v1/modules/{module_name}/", + "files": { + "models.py": "Definición ORM con SQLAlchemy", + "dto.py": "Definición de Pydantic DTOs para entrada/salida de datos (reemplaza schemas.py)", + "service.py": "Lógica de negocio y validaciones específicas del módulo", + "routes.py": "Endpoints FastAPI que usan los DTOs y servicios" + }, + "naming_convention": { + "models": "Representan entidades persistentes (Base de datos)", + "dto": "Data Transfer Objects para transporte entre capas y API", + "service": "Capa de negocio (domain logic)", + "routes": "Exposición HTTP / API layer" + }, + "reasoning": "Se utiliza dto.py en lugar de schemas.py para reflejar un enfoque DDD y estilo arquitectónico similar a NestJS, manteniendo compatibilidad total con FastAPI y Pydantic." + } + }, + "auth_system": { + "provider": "Keycloak", + "multi_tenant_model": "Un Realm por cliente (tenant)", + "roles": ["admin", "user", "auditor", "system"], + "license_validation": "Middleware que verifica licencia y plan activo antes de procesar cada request" + } + }, + "license_management": { + "strategy": "Control centralizado en core_db", + "table_structure": { + "tenant_id": "int", + "plan": "string", + "max_users": "int", + "expires_at": "datetime", + "status": "active|expired|pending" + }, + "upgrade_flow": "El cliente puede escalar de BD compartida a BD dedicada manteniendo mismo tenant_id y realm." + }, + "dev_ops": { + "containerization": "Docker / Docker Compose", + "orchestration": "Kubernetes (futuro)", + "monitoring": ["Prometheus", "Grafana"], + "ci_cd": "GitHub Actions o GitLab CI" + }, + "prompt_usage": { + "instruction": "Cuando uses este JSON, pide a la IA que genere o revise la arquitectura, código base o estrategia de despliegue respetando el modelo híbrido multi-tenant con Keycloak y FastAPI.", + "example_request": "Diseña un flujo de autenticación multi-tenant con Keycloak y FastAPI que detecte automáticamente el tenant y seleccione la base de datos correcta. Usa dto.py en lugar de schemas.py para mantener una arquitectura estilo DDD." + } +} diff --git a/docs/analisis_flujo_migracion_csv_transporte_sin_validaciones.md b/docs/analisis_flujo_migracion_csv_transporte_sin_validaciones.md new file mode 100644 index 0000000..730c66a --- /dev/null +++ b/docs/analisis_flujo_migracion_csv_transporte_sin_validaciones.md @@ -0,0 +1,141 @@ +# Analisis de flujo de migracion CSV de transporte (sin validaciones) + +## Objetivo + +Definir como migrar el flujo de importacion CSV de facturas hacia columnas nuevas de transporte, priorizando continuidad operativa y consistencia de IDs en `invoice_logistics`, sin depender de reglas de validacion funcional. + +## Estado actual de la rama + +- El pipeline actual ya separa `scan` y `commit` en `tasks.py`. +- El mapeo de plantillas (`template_config.py`) aun esta centrado en `NUMERO TRANSPORTE`. +- En `commit`, la persistencia de logistica usa principalmente: + - `carrier_id` / `carrier_int_id` desde `CLAVE TRANSPORTISTA`. + - `transport_type` desde `TIPO TRANSPORTE`. + - `transport_num` desde `NUMERO TRANSPORTE`. +- No existe aun una ruta establecida para columnas nuevas `CLAVE TRANSPORTE` y `NUMERO CAJA`. + +## Intencion funcional identificada en los commits analizados + +Sin copiar su logica de validacion, la idea de flujo que se quiso introducir es: + +1. Separar los datos de transporte en dos entradas semanticas: + - clave de vehiculo (`CLAVE TRANSPORTE`) + - numero de caja/remolque (`NUMERO CAJA`) +2. Mantener compatibilidad con archivos legacy: + - usar `NUMERO TRANSPORTE` como fallback cuando no existan columnas nuevas. +3. Resolver IDs internos en commit contra catalogos: + - `vehicle_key` -> `vehicle_id` + - `trailer_number` -> `trailer_id` +4. Persistir tanto la clave legible (string) como su ID interno (int) en `invoice_logistics`. +5. Mantener paridad de flujo entre scan y commit en cuanto a normalizacion y resolucion de campos, pero sin convertir scan en un bloqueo por validaciones de negocio. + +## Flujo propuesto (sin validaciones de negocio) + +### 1) Scan (preprocesamiento y trazabilidad) + +Objetivo: preparar datos y metadatos, no rechazar por reglas funcionales. + +- Leer CSV con `row_from_template`. +- Normalizar celdas relevantes de transporte: + - trim + - reemplazo de NBSP por espacio + - `None`/vacio a cadena vacia +- Construir una estructura por renglon con campos de transporte efectivos: + - `effective_transport_type` + - `effective_vehicle_key` + - `effective_trailer_number` + - `effective_legacy_transport_num` (solo trazabilidad) +- Guardar errores tecnicos (parseo CSV, formato bruto no interpretable), pero no bloquear por reglas de catalogo/negocio. + +### 2) Commit (resolucion y persistencia de IDs) + +Objetivo: persistir de forma consistente los IDs nuevos de migracion. + +- Determinar campos efectivos por precedencia (ver tabla siguiente). +- Resolver `carrier_int_id` con `CLAVE TRANSPORTISTA` (flujo ya existente). +- Resolver `transport_int_id` cuando exista `effective_vehicle_key`: + - lookup en `vehicle.vehicle_key` +- Resolver `trailer_int_id` cuando exista `effective_trailer_number`: + - lookup en `trailer.trailer_number` +- Persistir en `InvoiceLogistics`: + - string keys: `carrier_id`, `transport_id`, `trailer_num`, `transport_num` + - int refs: `carrier_int_id`, `transport_int_id`, `trailer_int_id` + +Importante: para migracion, si hay string pero no hay match de ID, **no romper flujo**; persistir string y dejar int en `NULL` (el FK ya permite `SET NULL`). + +## Matriz de mapeo CSV -> `invoice_logistics` + +| Entrada CSV | Rol | Campo destino (string) | Campo destino (int) | Catalogo/lookup | +|---|---|---|---|---| +| `CLAVE TRANSPORTISTA` | Transportista | `carrier_id` | `carrier_int_id` | `transporter.transporter_key -> transporter_id` | +| `CLAVE TRANSPORTE` | Vehiculo | `transport_id` | `transport_int_id` | `vehicle.vehicle_key -> vehicle_id` | +| `NUMERO CAJA` | Caja/Remolque | `trailer_num` | `trailer_int_id` | `trailer.trailer_number -> trailer_id` | +| `NUMERO TRANSPORTE` (legacy) | Fallback | `transport_num` (siempre trazable) y apoyo para resolver vehicle/trailer segun precedencia | opcional | depende de reglas de precedencia | +| `TIPO TRANSPORTE` | Tipo logistica | `transport_type` | n/a | enum interno | + +## Reglas de precedencia de datos (nuevas vs legacy) + +Definicion para no generar ambiguedad: + +1. Si viene `CLAVE TRANSPORTE`, usarla para `transport_id`. +2. Si viene `NUMERO CAJA`, usarla para `trailer_num`. +3. Si faltan columnas nuevas y viene `NUMERO TRANSPORTE`: + - usarlo como `transport_num` (trazabilidad legacy) + - y usarlo como fallback de resolucion para `transport_id`/`trailer_num` solo cuando el campo nuevo correspondiente este vacio. +4. Nunca sobreescribir un dato nuevo con legacy si el nuevo viene poblado. + +Sugerencia para parciales (`actualizar=true`): +- aplicar merge campo a campo: solo actualizar datos de transporte que lleguen informados en CSV; conservar los demas en la fila existente. + +## Relacion con la migracion de IDs + +La migracion `ca7d3c4e8b2a` ya formaliza: + +- `carrier_int_id` -> FK a `transporter.transporter_id` +- `transport_int_id` -> FK a `vehicle.vehicle_id` +- `trailer_int_id` -> FK a `trailer.trailer_id` + +Por lo tanto, el flujo CSV debe priorizar: + +- resolver claves string de catalogo de forma determinista +- poblar IDs internos cuando haya match +- mantener string keys para trazabilidad y backfill futuro + +## Impacto de implementacion por archivo (sin validaciones) + +### `backend/api/v1/modules/a76/layouts_csv/facturas/template_config.py` + +- Agregar columnas canonicas nuevas en encabezados: + - `CLAVE TRANSPORTE` + - `NUMERO CAJA` +- Mantener `NUMERO TRANSPORTE` como compatibilidad legacy (no eliminar de inmediato). +- Ajustar aliases para tolerar variantes de cabecera. + +### `backend/api/v1/modules/a76/layouts_csv/facturas/tasks.py` + +- Incorporar helper de resolucion de campos efectivos de transporte: + - prioridad nuevas columnas + - fallback legacy +- En `scan`, registrar estructura normalizada (sin rechazo por reglas de catalogo). +- En `commit`, poblar `InvoiceLogistics` con: + - `transport_id`/`transport_int_id` + - `trailer_num`/`trailer_int_id` + - `transport_num` como legado/trazabilidad +- Mantener comportamiento de no falla por ausencia de match de IDs. + +### Nuevo helper recomendado: `backend/api/v1/modules/a76/layouts_csv/facturas/validators/transport_catalog.py` + +- Aunque no se usen validaciones de negocio, centralizar funciones de: + - normalizacion de celdas + - resolucion de keys efectivas + - lookups de IDs de vehiculo/remolque +- Evita duplicar logica entre scan y commit. + +## Criterios de aceptacion de esta migracion de flujo + +- Queda definida una sola fuente de verdad para precedencia de columnas nuevas/legacy. +- `commit` persiste consistentemente claves string e IDs int de logistica. +- El flujo funciona aun cuando no haya match de catalogo (sin bloqueo por validacion). +- No hay ambiguedad entre: + - `transport_id` vs `transport_num` + - `transport_int_id` vs `trailer_int_id` diff --git a/docs/keyboard_shortcuts_alt_digit_matrix.md b/docs/keyboard_shortcuts_alt_digit_matrix.md new file mode 100644 index 0000000..a52ceb0 --- /dev/null +++ b/docs/keyboard_shortcuts_alt_digit_matrix.md @@ -0,0 +1,24 @@ +# Alt+Numero Keyboard Navigation Matrix + +This matrix documents the active `Alt+DigitN` shortcuts used for fast tab/view navigation in dashboard flows. + +## Active contexts + +| Context | Shortcut Source | Alt+Digit targets | Focus strategy | +| --- | --- | --- | --- | +| `Edit Broker Tabs` | `frontend/src/lib/config/shortcuts/dashboard/customs_brokers/edit.ts` | `general`, `contact`, `address`, `vu`, `doda`, `anam` | `first-input` | +| `Edit Client Provider` | `frontend/src/lib/config/shortcuts/dashboard/clients_and_providers/edit.ts` | `general`, `address`, `programs`, `config` | `first-input` | +| `Formulario Empresa` | `frontend/src/lib/config/shortcuts/dashboard/general_catalogs/company_information/edit.ts` | `general`, `programa`, `responsable`, `certificaciones`, `direcciones`, `config`, `certificados` | `first-input` | +| `Formulario DODA` | `frontend/src/lib/config/shortcuts/dashboard/general_catalogs/doda/edit.ts` | `general`, `transport`, `sat`, `other` | `first-input` | +| `Invoice Edit` | `frontend/src/lib/config/shortcuts/dashboard/invoices/edit.ts` | `general`, `observations`, `items`, `others`, `continuation` | `first-input` | +| `Invoice Item Form (Inventory)` | `frontend/src/lib/config/shortcuts/dashboard/invoices/item/inventory.ts` | `tab1..tab4` mapped to `general`, `clasificacion`, `cantidades`, `otros` | `first-input` | +| `Invoice Item Form (Fixed Asset)` | `frontend/src/lib/config/shortcuts/dashboard/invoices/item/fixed_asset.ts` | `tab1..tab5` mapped to `generales`, `continuacion`, `series`, `etiquetado`, `identificadores` | `first-input` | +| `Part Form` | `frontend/src/lib/config/shortcuts/dashboard/goods/edit.ts` | `general`, `cont1`, `cont2` | `first-input` | +| `Customs Brokers List` | `frontend/src/lib/config/shortcuts/dashboard/customs_brokers/list.ts` | `brokers`, `customs` | `trigger` | +| `Clients Providers List` | `frontend/src/lib/config/shortcuts/dashboard/clients_and_providers/list.ts` | `all`, `clients`, `providers` | `trigger` | + +## Notes + +- `KeyboardManager` now has explicit focus policy entries for all contexts listed above. +- `fixed_asset` shortcuts remain in place and are now connected in `item-sheet-fa`. +- No shortcut set was removed in this pass; changes are additive/corrective for keyboard-only navigation. diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..04b39b8 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,45 @@ +# ─── Copia este archivo a .env y ajusta los valores para dev local ───────────── +# ─── Para PRODUCCIÓN ver el bloque al final de este archivo ────────────────── + +# Auth local (sin Keycloak/Hub) — pon true para desarrollo standalone +# El backend también necesita DEV_LOCAL_AUTH=True +DEV_LOCAL_AUTH=false +# URL interna del backend (usada por el servidor SvelteKit en SSR, no por el browser) +BACKEND_URL=http://backend:8000 + +# API de Mi Aplicación (frontend y SSR) +VITE_API_URL=http://localhost:8000/api/ +INTERNAL_API_URL=http://localhost:8000/api/ + +# Hub Workspace +VITE_HUB_URL=http://localhost:3001 +HUB_URL=http://localhost:3001 +INTERNAL_HUB_URL=http://localhost:8001 + +# Keycloak — URL pública que el BROWSER usará (se bakea en el build) +VITE_KEYCLOAK_URL=http://localhost:8085/kcauth +VITE_KEYCLOAK_REALM=master +VITE_KEYCLOAK_CLIENT_ID=app-frontend +# Keycloak — URL interna que el SERVIDOR usará (no va al browser) +KEYCLOAK_URL=http://localhost:8085/kcauth +KEYCLOAK_REALM=master +KEYCLOAK_CLIENT_ID=app-frontend +# KEYCLOAK_CLIENT_SECRET= # solo si el cliente KC no es público + +# SvelteKit — necesario para cookies secure y URLs SSR correctas. +# ⚠️ En producción DEBE apuntar al dominio público real, no a localhost. +# Si este valor es localhost, url.origin en los load functions será localhost +# y los redirect_uri de Keycloak apuntarán a localhost (bug de login). +ORIGIN=http://localhost:5173 + +# ─── PRODUCCIÓN: vars adicionales críticas ──────────────────────────────────── +# SITE_URL es el fallback de seguridad cuando ORIGIN no se pudo corregir a tiempo. +# El código lo usa para construir redirect_uri cuando url.origin es localhost. +# Recomendado: definir TANTO ORIGIN como SITE_URL con el mismo valor en prod. +# +# SITE_URL=https://anexo76-dev.aduanasoft.com +# ORIGIN=https://anexo76-dev.aduanasoft.com +# VITE_HUB_URL=https://hub-dev.aduanasoft.com (o la URL del Hub en prod) +# HUB_URL=https://hub-dev.aduanasoft.com +# VITE_KEYCLOAK_URL= # vacío → se deriva del hostname del browser automáticamente +# KEYCLOAK_URL=http://keycloak:8080 # URL interna del contenedor KC (si en Docker) diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..ce9f3eb --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,35 @@ +test-results +# Playwright E2E: no versionar sesión, reportes ni estado compartido generado +e2e/.auth/user.json +e2e/.e2e-*.json +playwright-report/ +blob-report/ + +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +.vite/ +vite.config.js.timestamp-* +vite.config.ts.timestamp-* + +# Paraglide +src/lib/paraglide +frontend/project.inlang/cache/ \ No newline at end of file diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000..7d74fe2 --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,9 @@ +# Package Managers +package-lock.json +pnpm-lock.yaml +yarn.lock +bun.lock +bun.lockb + +# Miscellaneous +/static/ diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 0000000..8103a0b --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,16 @@ +{ + "useTabs": true, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100, + "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], + "overrides": [ + { + "files": "*.svelte", + "options": { + "parser": "svelte" + } + } + ], + "tailwindStylesheet": "./src/app.css" +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..c5d6a7d --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,34 @@ +FROM node:20-alpine + +WORKDIR /app + +# Instalar dependencias del sistema necesarias para healthchecks +RUN apk update && apk add --no-cache ca-certificates wget && update-ca-certificates + +# Copiar package files +COPY package.json pnpm-lock.yaml ./ + +# Instalar pnpm +RUN npm config set strict-ssl false +RUN npm install -g pnpm + +# Instalar dependencias +RUN pnpm install + +# Entrypoint (espera API backend en dev; no montar desde el host) +COPY docker-entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Copiar código +COPY . . + +# Build (para producción) +# RUN pnpm run build + +# Exponer puerto +EXPOSE 5173 + +ENTRYPOINT ["/entrypoint.sh"] + +# Comando por defecto (desarrollo) +CMD ["pnpm", "run", "dev", "--", "--host", "0.0.0.0"] diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod new file mode 100644 index 0000000..336991d --- /dev/null +++ b/frontend/Dockerfile.prod @@ -0,0 +1,89 @@ +# ========================== +# Etapa de build +# ========================== +FROM node:22-alpine AS build + +# Directorio de trabajo +WORKDIR /app + +# Configurar npm para trabajar con certificados autofirmados e instalar pnpm +RUN npm config set strict-ssl false && \ + npm install -g pnpm + +# Copiar archivos de dependencias +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ + +# Instalar dependencias con pnpm +RUN pnpm install --frozen-lockfile + +ARG VITE_API_URL +ENV VITE_API_URL=${VITE_API_URL} + +ARG VITE_KEYCLOAK_URL +ENV VITE_KEYCLOAK_URL=${VITE_KEYCLOAK_URL} + +ARG VITE_KEYCLOAK_REALM=master +ENV VITE_KEYCLOAK_REALM=${VITE_KEYCLOAK_REALM} + +ARG VITE_KEYCLOAK_CLIENT_ID=anexo76-frontend +ENV VITE_KEYCLOAK_CLIENT_ID=${VITE_KEYCLOAK_CLIENT_ID} + +# URL pública del Hub Workspace (bakeada en build para el browser) +ARG VITE_HUB_URL=https://workspace.aduanasoft.com +ENV VITE_HUB_URL=${VITE_HUB_URL} + +ARG INTERNAL_API_URL +ENV INTERNAL_API_URL=${INTERNAL_API_URL} + +# Copiar el resto del código +COPY . . + +# Construir el proyecto +RUN pnpm run build + + +# ========================== +# Etapa de ejecución con Node.js +# ========================== +FROM node:22-alpine AS runtime + +WORKDIR /app + +RUN apk add --no-cache wget + +RUN npm config set strict-ssl false && \ + npm install -g pnpm + +# Crear usuario no-root para seguridad antes de copiar con --chown +RUN addgroup -g 1001 -S nodejs +RUN adduser -S svelte -u 1001 + +COPY docker-entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Copiar solo archivos necesarios para producción y aplicar propietario en la copia +COPY --from=build --chown=svelte:nodejs /app/build ./build +COPY --from=build --chown=svelte:nodejs /app/package.json ./ +COPY --from=build --chown=svelte:nodejs /app/node_modules ./node_modules + +# WORKDIR /app queda owned por root; pnpm necesita crear _tmp_* en el cwd al ejecutar scripts. +RUN chown svelte:nodejs /app + +USER svelte + +# Puerto para SvelteKit con adapter-node +EXPOSE 5173 + +# Variables de entorno +ENV NODE_ENV=production +ENV PORT=5173 +ENV HOST=0.0.0.0 + +# IMPORTANTE: Estas variables se pueden sobrescribir en docker-compose +# pero necesitamos valores por defecto para el build +ENV INTERNAL_API_URL=http://backend:8000/api/ + +ENTRYPOINT ["/entrypoint.sh"] + +# Ejecutar aplicación con Node.js +CMD ["pnpm", "start"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..47da320 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,41 @@ +# sv + +Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). + +## Creating a project + +If you're seeing this, you've probably already done this step. Congrats! + +```sh +# create a new project in the current directory +npx sv create + +# create a new project in my-app +npx sv create my-app + +# compile paraglide +cd frontend && sudo rm -rf src/lib/paraglide && pnpm paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide +``` + +## Developing + +Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: + +```sh +npm run dev + +# or start the server and open the app in a new browser tab +npm run dev -- --open +``` + +## Building + +To create a production version of your app: + +```sh +npm run build +``` + +You can preview the production build with `npm run preview`. + +> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..f258682 --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://shadcn-svelte.com/schema.json", + "tailwind": { + "css": "src/app.css", + "baseColor": "zinc" + }, + "aliases": { + "components": "$lib/components", + "utils": "$lib/utils", + "ui": "$lib/components/ui", + "hooks": "$lib/hooks", + "lib": "$lib" + }, + "typescript": true, + "registry": "https://shadcn-svelte.com/registry" +} diff --git a/frontend/docker-entrypoint.sh b/frontend/docker-entrypoint.sh new file mode 100644 index 0000000..31aacf2 --- /dev/null +++ b/frontend/docker-entrypoint.sh @@ -0,0 +1,44 @@ +#!/bin/sh +set -e + +# Arranque del contenedor: opcionalmente espera al health del backend, luego ejecuta CMD (pnpm dev / start, …) + +wait_for_backend() { + local url=$1 + local max_attempts=30 + local attempt=1 + + echo "Esperando a que el backend esté disponible en ${url}..." + + while [ $attempt -le $max_attempts ]; do + # NOTA: usar la URL tal cual la pasa el caller. Antes esta función agregaba + # un "/health" extra al final (resultando en /api/health/health → 404), lo + # que provocaba que el entrypoint esperara 30 intentos en vano antes de + # arrancar Vite, sumando ~90 s muertos al arranque del frontend. + if wget -q -O /dev/null "${url}" 2>/dev/null; then + echo "✓ Backend está listo" + return 0 + fi + + echo "Backend no está listo aún... (intento $attempt/$max_attempts)" + attempt=$((attempt + 1)) + sleep 3 + done + + echo "⚠ WARNING: Backend no estuvo disponible, continuando de todas formas" + return 0 +} + +# compose usa INTERNAL_API_URL; alias opcional BACKEND_INTERNAL_URL +_api_base="${INTERNAL_API_URL:-${BACKEND_INTERNAL_URL:-http://backend:8000/api}}" +_api_base="${_api_base%/}" +wait_for_backend "${_api_base}/health" + +# En desarrollo (volumen montado) reinstala deps si cambia package.json +if [ "$NODE_ENV" = "development" ]; then + echo "Instalando dependencias (development)..." + CI=true pnpm install +fi + +echo "Iniciando: $*" +exec "$@" diff --git a/frontend/e2e/0-setup-catalogs.spec.ts b/frontend/e2e/0-setup-catalogs.spec.ts new file mode 100644 index 0000000..65e11e2 --- /dev/null +++ b/frontend/e2e/0-setup-catalogs.spec.ts @@ -0,0 +1,188 @@ +import { test, expect, type Page } from '@playwright/test' +import * as fs from 'fs' +import { fileURLToPath } from 'url' +import * as path from 'path' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const SHARED_FILE = path.join(__dirname, '.e2e-catalog.json') + +function saveCatalog(data: Record) { + fs.writeFileSync(SHARED_FILE, JSON.stringify(data)) +} + +// Clase y parte con fraccion valida del catalogo SITAR +// 8471.30.01 es una fraccion comun para equipos de computo — existe en SITAR +const CLASS_CODE = 'E2E01' +const CLASS_FRACTION = '12787' +// HTS-style code resolvable via SITAR fracciones-usa (8 or 10 digit patterns used in UI) +const CLASS_US_FRACTION = '8471300100' +const CLASS_UM = 'KGS' +const CLASS_MATERIAL_KEY = 'MP' +const CLASS_DESC_ES = 'Clase E2E Test' +const CLASS_DESC_EN = 'E2E Test Class' + +const PART_NUMBER = 'E2E-PART-001' +const PART_DESC_ES = 'Parte E2E Test' + +async function fillInput(page: Page, selector: string, value: string) { + await page.locator(selector).click() + await page.evaluate(({ sel, val }: { sel: string; val: string }) => { + const el = document.querySelector(sel) as HTMLInputElement + if (!el) return + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set + setter?.call(el, val) + el.dispatchEvent(new Event('input', { bubbles: true })) + el.dispatchEvent(new Event('change', { bubbles: true })) + }, { sel: selector, val: value }) + await page.waitForTimeout(300) +} + +test.describe('Setup — Catalogos para pruebas E2E', () => { + + test('1. crear clase valida', async ({ page }) => { + await page.goto('/dashboard/goods/fixed-asset-classes') + await page.waitForLoadState('networkidle') + + // Abrir dialog de nueva clase + await page.getByRole('button', { name: /Insertar|Nueva Clase|Nuevo/ }).first().click() + await page.waitForTimeout(800) + + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }) + + // Llenar campos + await page.locator('#class_code').scrollIntoViewIfNeeded() + await fillInput(page, '#class_code', CLASS_CODE) + + await page.locator('#material_key').scrollIntoViewIfNeeded() + await fillInput(page, '#material_key', CLASS_MATERIAL_KEY) + + await page.locator('#description_es').scrollIntoViewIfNeeded() + await fillInput(page, '#description_es', CLASS_DESC_ES) + + await page.locator('#description_en').scrollIntoViewIfNeeded() + await fillInput(page, '#description_en', CLASS_DESC_EN) + + await page.locator('#unit_of_measure').scrollIntoViewIfNeeded() + await fillInput(page, '#unit_of_measure', CLASS_UM) + + await page.locator('#fraction').scrollIntoViewIfNeeded() + await fillInput(page, '#fraction', CLASS_FRACTION) + // El input #fraction abre TariffFractionSelector (SITAR) como dialog anidado. + // El onclick está en el — hacer click forzado en la primera fila lo cierra. + await page.waitForTimeout(1500) + const sitarDialog = page.getByRole('dialog', { name: /SITAR/i }) + if (await sitarDialog.isVisible().catch(() => false)) { + await sitarDialog.locator('tbody tr').first() + .click({ force: true, timeout: 5000 }) + .catch(async () => { + // Fallback: cerrar con Close si no podemos clickear fila + await sitarDialog.getByRole('button', { name: /Close|Cerrar/ }) + .click({ force: true }).catch(() => {}) + }) + await sitarDialog.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {}) + } + + await page.locator('#us_fraction').scrollIntoViewIfNeeded() + await fillInput(page, '#us_fraction', CLASS_US_FRACTION) + await page.waitForTimeout(1500) + // Mismo patrón para catálogo de fracciones americanas si abre. + const usDialog = page.getByRole('dialog', { name: /AMERICANA|US/i }) + if (await usDialog.isVisible().catch(() => false)) { + await usDialog.locator('tbody tr').first() + .click({ force: true, timeout: 5000 }) + .catch(async () => { + await usDialog.getByRole('button', { name: /Close|Cerrar/ }) + .click({ force: true }).catch(() => {}) + }) + await usDialog.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {}) + } + + // Guardar + await page.getByRole('button', { name: /^Guardar$/ }).scrollIntoViewIfNeeded() + await page.getByRole('button', { name: /^Guardar$/ }).click() + await page.waitForTimeout(3000) + + // Si aparece un error visible, mostrarlo para diagnóstico (no toleramos silencioso). + const errorEl = page.locator('.text-destructive, [role="alert"]').first() + if (await errorEl.isVisible({ timeout: 500 }).catch(() => false)) { + const txt = await errorEl.textContent() + console.log('[DEBUG][crear clase] Error visible:', txt) + } + + saveCatalog({ CLASS_CODE, PART_NUMBER }) + await expect(page.locator('main')).toBeVisible() + }) + + test('2. verificar clase en tabla', async ({ page }) => { + await page.goto('/dashboard/goods/fixed-asset-classes') + await page.waitForLoadState('networkidle') + + // Buscar la clase creada en la tabla + const classRow = page.locator('tbody').getByText(CLASS_CODE) + if (await classRow.isVisible({ timeout: 5000 }).catch(() => false)) { + await expect(classRow.first()).toBeVisible() + } else { + // La clase puede no aparecer si ya existia — OK + console.log('Clase no encontrada en tabla — puede ya existir con otro nombre') + } + }) + + test('3. crear parte valida', async ({ page }) => { + // Las partes se crean en /dashboard/goods/parts/edit/new + await page.goto('/dashboard/goods/parts/edit/new') + await page.waitForLoadState('networkidle') + await page.waitForTimeout(800) + + // PrerequisitesModal: AlertDialog "Aviso" que abre cuando la DB no tiene + // Agentes aduanales o Clientes registrados (caso típico en E2E con DB limpia). + // Bloquea pointer events con un overlay sobre todo el form. Cancelar te saca + // del editor; Aceptar lo cierra y permite continuar. + // Ver routes/dashboard/goods/parts/edit/[[id]]/+page.svelte:29-38. + const prerequisitesDialog = page.getByRole('alertdialog', { name: /Aviso/i }) + if (await prerequisitesDialog.isVisible({ timeout: 2000 }).catch(() => false)) { + await prerequisitesDialog.getByRole('button', { name: /^Aceptar$/ }).click() + await prerequisitesDialog.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {}) + } + + // Llenar campos del formulario de nueva parte + // Numero de parte + const partInput = page.locator('#part_number, input[placeholder*="parte"], input[placeholder*="número"], input[name="part_number"]').first() + if (await partInput.isVisible({ timeout: 3000 }).catch(() => false)) { + await partInput.scrollIntoViewIfNeeded() + await fillInput(page, '#part_number', PART_NUMBER).catch(async () => { + await partInput.fill(PART_NUMBER) + }) + } + + // Descripcion en español + const descInput = page.locator('#description_es, textarea[placeholder*="español"], input[placeholder*="escripción"]').first() + if (await descInput.isVisible({ timeout: 2000 }).catch(() => false)) { + await descInput.scrollIntoViewIfNeeded() + await descInput.fill(PART_DESC_ES).catch(() => {}) + } + + // Clase — puede ser un input o select + const claseInput = page.locator('#class_code, #clase, input[placeholder*="Clase"], input[placeholder*="clase"]').first() + if (await claseInput.isVisible({ timeout: 2000 }).catch(() => false)) { + await claseInput.scrollIntoViewIfNeeded() + await claseInput.fill(CLASS_CODE).catch(() => {}) + await page.waitForTimeout(500) + const option = page.getByRole('option').first() + if (await option.isVisible({ timeout: 1000 }).catch(() => false)) { + await option.click() + } + } + + // Guardar + const saveBtn = page.getByRole('button', { name: /Guardar|Crear|Insertar/ }).first() + if (await saveBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await saveBtn.scrollIntoViewIfNeeded() + await saveBtn.click() + await page.waitForTimeout(1000) + } + + await expect(page.locator('main')).toBeVisible() + }) + +}) \ No newline at end of file diff --git a/frontend/e2e/FlujoCompleto.MD b/frontend/e2e/FlujoCompleto.MD new file mode 100644 index 0000000..061823b --- /dev/null +++ b/frontend/e2e/FlujoCompleto.MD @@ -0,0 +1,248 @@ +# Reporte de Pruebas E2E — Flujo de Factura + +**Proyecto:** Anexo 76 — Sistema de Control de Operaciones Aduaneras +**Herramienta:** Playwright +**Archivo:** `frontend/e2e/invoice-flow.spec.ts` +**Fecha:** Abril 2026 +**Estado:** 12/12 pruebas pasando ✅ +**Tiempo de ejecución:** ~4.4 minutos + +--- + +## Resumen + +| Categoría | Pruebas | Estado | +|-----------|---------|--------| +| Prerrequisitos (proveedor, cliente, agente, TC) | 4 | ✅ | +| Pedimento | 1 | ✅ | +| Factura TEM | 3 | ✅ | +| Partidas | 2 | ✅ | +| Actualización final | 1 | ✅ | +| **Total** | **11** | **✅** | + +> Nota: el test de setup de autenticación (`auth.setup.ts`) suma 1 prueba adicional, totalizando 12 en el runner. + +--- + +## Flujo completo + +### 1. Crear proveedor + +Navega a `/dashboard/clients_and_providers`, abre el formulario de nuevo socio, llena RFC y nombre, selecciona tipo "Proveedor" y guarda. Verifica redirección a la lista y que el nombre aparece en la tabla. + +### 2. Crear cliente + +Mismo flujo que el proveedor pero con tipo "Cliente". + +### 3. Crear agente aduanal + +Navega a `/dashboard/customs_brokers`, abre el formulario, llena clave, patente y nombre. Verifica toast de éxito y redirección. + +### 4. Crear tipo de cambio + +Navega a `/dashboard/general_catalogs/exchange-rate`, abre el modal de nuevo tipo de cambio, llena fecha de hoy y valor `17.5`, confirma. Verifica toast de éxito. + +### 5. Crear pedimento + +Navega a `/dashboard/pedimentos/edit/new`, llena año (`26`), selecciona Aduana, Patente, Clave, Tipo de Operación y Régimen via bits-ui Select. Llena número de pedimento. Guarda y verifica redirección a `/dashboard/pedimentos`. + +### 6. Crear factura de importación TEM + +Navega a `/dashboard/invoices/edit/new?operation_type=imp&invoice_type=TEM`. Llena número de factura con `pressSequentially`, fecha, y en la pestaña General selecciona proveedor, sold-to, shipped-to, agente aduanal, aduana y tipo de documento. Guarda y verifica toast de éxito. Al finalizar guarda el número de factura en `.e2e-shared.json` para los tests posteriores. + +### 7. Factura aparece en la lista + +Filtra por número de factura en la lista de importación y verifica que la fila es visible. + +### 8. Agregar partida a la factura + +Abre la factura desde la lista, navega a la pestaña Partidas, abre el sheet de nueva partida. Selecciona Clase, U.M. y País de Origen (cada uno abre un dialog con tabla). Llena cantidad (`10`), costo unitario (`100`), peso neto (`5`), peso bruto (`6`) y descripción en español. Hace click en el botón "Crear" del sheet. Guarda la factura completa. + +### 9. Editar factura existente + +Lee el número de factura desde `.e2e-shared.json`, la busca en la lista, la abre en modo edición. En la pestaña General vuelve a seleccionar agente aduanal, aduana y tipo de documento. Guarda y verifica toast de éxito. + +### 10. Editar partida existente + +Lee el número desde shared, abre la factura, va a pestaña Partidas. Hace click en el ícono Pencil de la primera fila para abrir el sheet de edición. Modifica cantidad (`20`) y costo unitario (`200`). Hace click en "Actualizar" del sheet. Guarda la factura. + +### 11. Actualizar factura — verificación final + +Lee el número desde shared, busca la factura en la lista, selecciona la fila, hace click en el botón "Actualizar" del footer (ícono RefreshCw, clase `h-8`). Verifica el resultado con toast de éxito. + +--- + +## Patrones técnicos establecidos + +### fillInput — inputs reactivos de Svelte 5 + +Los inputs de Svelte 5 no responden a `page.fill()` ni `pressSequentially` de forma confiable. La solución es usar el native setter del prototipo: + +```typescript +async function fillInput(page: Page, selector: string, value: string) { + await page.locator(selector).click() + await page.evaluate(({ sel, val }) => { + const el = document.querySelector(sel) as HTMLInputElement + const setter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, 'value' + )?.set + setter?.call(el, val) + el.dispatchEvent(new Event('input', { bubbles: true })) + el.dispatchEvent(new Event('change', { bubbles: true })) + }, { sel: selector, val: value }) + await page.waitForTimeout(2000) +} +``` + +La excepción es `#invoice_number`, que sí responde a `pressSequentially` con delay: + +```typescript +await page.locator('#invoice_number').click() +await page.keyboard.press('Control+A') +await page.locator('#invoice_number').pressSequentially(INVOICE_NUMBER, { delay: 1000 }) +``` + +### bits-ui Select — selects con IDs dinámicos + +Los selects de bits-ui generan IDs como `bits-s65` que cambian en cada render. La estrategia es seleccionarlos por el atributo `data-select-trigger` y posición: + +```typescript +const triggers = page.locator('[data-select-trigger]') +await triggers.nth(0).click() // Aduana +await page.getByRole('option').first().click() +``` + +Para selects con IDs estables (facturas) se usa directamente: + +```typescript +await page.locator('#provider_id').click() +await page.getByRole('option').first().click() +``` + +### Dialogs anidados — clase, U.M., país de origen + +Los campos Clase, U.M. y País de Origen abren un dialog de búsqueda encima del sheet. Para evitar que el sheet intercepte los clicks, se scopea al último dialog abierto: + +```typescript +await page.locator('#clase').click() +await page.waitForTimeout(3000) +const claseDialog = page.locator('[data-dialog-content]').last() +await claseDialog.locator('tbody tr').first().click() +``` + +### Botones dentro del sheet + +El botón de guardar partida está dentro del sheet y puede ser interceptado. Se scopea explícitamente: + +```typescript +const sheet = page.locator('[data-slot="sheet-content"]') +await sheet.getByRole('button', { name: /Crear/ }).click() // nueva partida +await sheet.getByRole('button', { name: /Actualizar/ }).click() // editar partida +``` + +### Distinguir botones ambiguos por clase CSS + +Cuando hay múltiples botones con el mismo texto o ícono, se distinguen por clases CSS únicas: + +```typescript +// Botón Actualizar del footer (tiene h-8, border, RefreshCw) +await page.locator('button.h-8:has([class*="lucide-refresh"])').click() +``` + +### Compartir estado entre tests + +Playwright corre cada test en un worker separado, por lo que `Date.now()` se reevalúa. Para compartir el número de factura entre tests se usa un archivo JSON: + +```typescript +// Al final del test 6 +saveShared({ INVOICE_NUMBER }) + +// En tests 7-11 +const shared = loadShared() +const invoiceNumber = shared.INVOICE_NUMBER || INVOICE_NUMBER +``` + +El archivo se guarda en `frontend/e2e/.e2e-shared.json`. + +--- + +## Selectores de referencia + +| Campo | Selector | Tipo | +|-------|----------|------| +| RFC | `#rfc` | input normal | +| Nombre | `#name` | input normal | +| Tipo de socio | `#type` | bits-ui Select | +| Año pedimento | `#year` | input normal | +| Número pedimento | `#pedimento_number` | input normal | +| Aduana pedimento | `[data-select-trigger]` nth(0) | bits-ui Select | +| Patente pedimento | `[data-select-trigger]` nth(1) | bits-ui Select | +| Clave pedimento | `[data-select-trigger]` nth(2) | bits-ui Select | +| Número factura | `#invoice_number` | input (pressSequentially) | +| Fecha factura | `#invoice_date` | date input | +| Proveedor | `#provider_id` | bits-ui Select | +| Sold-to | `#sold_to_id` | bits-ui Select | +| Shipped-to | `#shipped_to_id` | bits-ui Select | +| Agente aduanal | `#customs_broker_id` | bits-ui Select | +| Aduana factura | `#aduana` | bits-ui Select | +| Tipo documento | `#document_type` | bits-ui Select | +| Clase partida | `#clase` | input readonly → dialog | +| U.M. | `#um` | input readonly → dialog | +| País origen | `#pais_origen` | input readonly → dialog | +| Cantidad | `#cantidad` | input number | +| Costo unitario | `#costo_unitario` | input number | +| Peso neto | `#peso_neto` | input number | +| Peso bruto | `#peso_bruto` | input number | +| Descripción ES | `#desc_espanol` | textarea | +| Filtro número | `#filter-invoice-number` | input normal | + +--- + +## Comandos + +```bash +# Flujo completo +pnpm test:e2e --grep "Flujo completo" + +# Test individual +pnpm test:e2e --grep "5. crear pedimento" +pnpm test:e2e --grep "8. agregar partida" + +# Modo visual para debug +pnpm test:e2e --grep "Flujo completo" --headed --timeout 120000 +``` + +--- + +## Estructura de archivos + +``` +frontend/e2e/ +├── .auth/ +│ └── user.json sesion de autenticacion +├── .e2e-shared.json estado compartido entre tests (generado) +├── auth.setup.ts 1 test — login y guardado de sesion +├── invoice-flow.spec.ts 11 tests — flujo completo de factura +├── full-flow.spec.ts 4 tests +├── login.spec.ts 3 tests +├── navigation.spec.ts 10 tests +└── modules.spec.ts 10 tests +``` + +--- + +## Conteo total actualizado + +| Suite | Pruebas | +|-------|---------| +| auth.setup.ts | 1 | +| invoice-flow.spec.ts | 11 | +| full-flow.spec.ts | 4 | +| login.spec.ts | 3 | +| navigation.spec.ts | 10 | +| modules.spec.ts | 10 | +| **Total Playwright** | **39** | + +--- + +*Anexo 76 — Reporte de Pruebas E2E v4.0 — invoice-flow — Abril 2026* \ No newline at end of file diff --git a/frontend/e2e/auth.setup.ts b/frontend/e2e/auth.setup.ts new file mode 100644 index 0000000..d0cab76 --- /dev/null +++ b/frontend/e2e/auth.setup.ts @@ -0,0 +1,93 @@ +/** + * Setup de autenticación para tests E2E de Playwright. + * + * El flujo de login de Anexo76 pasa por Workspace (workspace.aduanasoft.com): + * 1. Navegar a /login → 303 redirect al formulario de Workspace + * 2. Llenar el form de Workspace con el usuario de prueba (creado en globalSetup) + * 3. Workspace/Keycloak redirige de vuelta a /auth/callback → /dashboard + * 4. Guardar storageState para todos los tests dependientes + * + * El usuario de prueba es creado y eliminado por globalSetup / globalTeardown. + * Patrón idéntico al de aduanasoft-hub. + */ +import { test as setup, expect } from '@playwright/test' +import { mkdirSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const authFile = path.join(__dirname, '.auth/user.json') + +setup('autenticacion', async ({ page }) => { + const username = process.env.E2E_TEST_USER ?? '' + const password = process.env.E2E_TEST_PASSWORD ?? '' + + if (!username) throw new Error('E2E_TEST_USER no está configurado') + if (!password) throw new Error('E2E_TEST_PASSWORD no está configurado') + + // ── 1. Navegar a /login — Workspace intercepta y muestra su formulario ── + await page.goto('/login') + + // ── 2. Llenar el formulario de Workspace (mismos selectores que hub/login.spec.ts) ── + await page.getByRole('textbox', { name: /usuario o email/i }).fill(username) + await page.getByLabel(/contraseña/i).fill(password) + await page.getByRole('button', { name: /continuar/i }).click() + + // ── 3. Si Workspace muestra el app-launcher (usuario con múltiples apps), + // elegir anexo76-dev — la app del launcher cuya URL apunta al frontend bajo prueba. + // Cuando hay match directo de dominio, Workspace salta el launcher y este paso no ejecuta. + try { + await page.waitForURL(/\/(app-launcher|dashboard|auth\/callback)/, { timeout: 30_000 }) + } catch (e) { + // Si seguimos en /login de Workspace, las credenciales fueron rechazadas o el form no aceptó. + // Imprimir diagnóstico antes de fallar: URL actual + posibles mensajes de error visibles. + const currentUrl = page.url() + const errorMessages = await page.locator('.text-destructive, [role="alert"], .error, .invalid-feedback') + .allTextContents() + .catch(() => [] as string[]) + const visibleText = await page.locator('body').innerText().catch(() => '') + console.log(`[auth.setup][DIAG] URL: ${currentUrl}`) + console.log(`[auth.setup][DIAG] Mensajes de error: ${JSON.stringify(errorMessages)}`) + console.log(`[auth.setup][DIAG] Body (primeros 800 chars): ${visibleText.slice(0, 800)}`) + throw e + } + if (/\/app-launcher/.test(page.url())) { + // Nuevo botón de Fixed Assets en el launcher de Workspace. + // Intentar primero el botón de Fixed Asset y, si no existe (compatibilidad), + // caer al botón legacy de anexo76-dev. + const fixedAssetButton = page.getByRole('button', { + name: /fixed asset|activo fijo/i + }) + if (await fixedAssetButton.isVisible().catch(() => false)) { + await fixedAssetButton.click() + } else { + await page.getByRole('button', { name: /^anexo76-dev/i }).click() + } + } + + // ── 4. Esperar redirect SSO con active_system=fixed_asset y luego /dashboard ── + await page.waitForURL(/\/auth\/sso\?.*active_system=fixed_asset/, { timeout: 30_000 }) + await page.waitForURL(/\/dashboard/, { timeout: 30_000 }) + + // ── 5. ExchangeRateGuard abre un dialog modal en cada carga del dashboard + // si no existe un tipo de cambio para hoy. El guard espera a que + // companyStore.activeCompany?.id esté disponible (API async) antes de + // consultar la DB y abrir el dialog. Esperar networkidle para que ese + // flujo se complete antes de buscar el dialog. + await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {}) + const tcDialog = page.getByRole('dialog', { name: /Nuevo Tipo de Cambio/i }) + if (await tcDialog.isVisible({ timeout: 10_000 }).catch(() => false)) { + const valueInput = tcDialog.getByRole('spinbutton', { name: /Tipo de Cambio/i }) + await tcDialog.getByRole('button', { name: /Consultar DOF/i }).click() + // El backend consulta el DOF y llena el campo. Esperar a que tenga un valor. + await expect(valueInput).not.toHaveValue('', { timeout: 15_000 }) + // "Ok" abre un AlertDialog de confirmación; el TC se crea hasta "Confirmar". + await tcDialog.getByRole('button', { name: /^Ok$/ }).click() + await page.getByRole('button', { name: /^Confirmar$/ }).click() + await tcDialog.waitFor({ state: 'hidden', timeout: 10_000 }) + } + + // ── 6. Guardar estado para los tests dependientes ───────────────────────── + mkdirSync(path.dirname(authFile), { recursive: true }) + await page.context().storageState({ path: authFile }) +}) diff --git a/frontend/e2e/demo.test.ts b/frontend/e2e/demo.test.ts new file mode 100644 index 0000000..9985ce1 --- /dev/null +++ b/frontend/e2e/demo.test.ts @@ -0,0 +1,6 @@ +import { expect, test } from '@playwright/test'; + +test('home page has expected h1', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('h1')).toBeVisible(); +}); diff --git a/frontend/e2e/export-flow.spec.ts b/frontend/e2e/export-flow.spec.ts new file mode 100644 index 0000000..765e976 --- /dev/null +++ b/frontend/e2e/export-flow.spec.ts @@ -0,0 +1,529 @@ +import { test, expect, type Page } from '@playwright/test' +import * as fs from 'fs' +import { fileURLToPath } from 'url' +import * as path from 'path' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const SHARED_FILE = path.join(__dirname, '.e2e-shared-exp.json') + +function saveShared(data: Record) { + fs.writeFileSync(SHARED_FILE, JSON.stringify(data)) +} + +function loadShared(): Record { + try { + return JSON.parse(fs.readFileSync(SHARED_FILE, 'utf-8')) + } catch { + return {} + } +} + +const SUFFIX = 'X' + Date.now().toString().slice(-5) // prefijo X para exportacion +const TODAY = new Date().toISOString().split('T')[0] + +const HOMOCLAVE = SUFFIX.slice(-3).toUpperCase() + +const PROVEEDOR_RFC = `XAXX010101${HOMOCLAVE}` +const PROVEEDOR_NOMBRE = `Proveedor E2E ${SUFFIX}` + +const CLIENTE_RFC = `XBXX010101${HOMOCLAVE}` +const CLIENTE_NOMBRE = `Cliente E2E ${SUFFIX}` + +const BROKER_KEY = `T${SUFFIX.slice(-4)}` +const BROKER_LICENSE = SUFFIX.slice(-4).replace(/^0/, '1') +const INVOICE_NUMBER = `E2E-${SUFFIX}` + +const PEDIMENTO_YEAR = '26' +const PEDIMENTO_OFFICE = '240' +const PEDIMENTO_LICENSE = '3101' +const PEDIMENTO_NUMBER = SUFFIX.slice(-5) // 5 digitos para pedimento + +async function fillInput(page: Page, selector: string, value: string) { + await page.locator(selector).click() + await page.evaluate(({ sel, val }: { sel: string; val: string }) => { + const el = document.querySelector(sel) as HTMLInputElement + if (!el) return + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set + setter?.call(el, val) + el.dispatchEvent(new Event('input', { bubbles: true })) + el.dispatchEvent(new Event('change', { bubbles: true })) + }, { sel: selector, val: value }) + await page.waitForTimeout(300) +} + +async function fillTextarea(page: Page, selector: string, value: string) { + await page.locator(selector).click() + await page.evaluate(({ sel, val }: { sel: string; val: string }) => { + const el = document.querySelector(sel) as HTMLTextAreaElement + if (!el) return + const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set + setter?.call(el, val) + el.dispatchEvent(new Event('input', { bubbles: true })) + el.dispatchEvent(new Event('change', { bubbles: true })) + }, { sel: selector, val: value }) + await page.waitForTimeout(500) +} + +test.describe('Flujo completo — creacion y actualizacion de factura EXPORTACION', () => { + + test('1. crear proveedor', async ({ page }) => { + await page.goto('/dashboard/clients_and_providers') + await page.waitForLoadState('networkidle') + + await page.getByRole('link', { name: /Nuevo/ }).click() + await expect(page).toHaveURL(/edit/, { timeout: 10000 }) + await page.waitForLoadState('networkidle') + + await fillInput(page, '#rfc', PROVEEDOR_RFC) + await fillInput(page, '#name', PROVEEDOR_NOMBRE) + + await page.locator('#type').click() + await page.getByRole('option', { name: 'Proveedor' }).click() + + await page.waitForTimeout(500) + + await page.getByRole('button', { name: /Guardar Socio|Actualizar Socio/ }).click() + + await expect(page).toHaveURL(/clients_and_providers$/, { timeout: 15000 }) + await expect(page.getByText(PROVEEDOR_NOMBRE)).toBeVisible() + }) + + test('2. crear cliente', async ({ page }) => { + await page.goto('/dashboard/clients_and_providers') + await page.waitForLoadState('networkidle') + + await page.getByRole('link', { name: /Nuevo/ }).click() + await expect(page).toHaveURL(/edit/, { timeout: 10000 }) + await page.waitForLoadState('networkidle') + + await fillInput(page, '#rfc', CLIENTE_RFC) + await fillInput(page, '#name', CLIENTE_NOMBRE) + + await page.locator('#type').click() + await page.getByRole('option', { name: 'Cliente' }).click() + + await page.waitForTimeout(500) + + await page.getByRole('button', { name: /Guardar Socio|Actualizar Socio/ }).click() + + await expect(page).toHaveURL(/clients_and_providers$/, { timeout: 15000 }) + await expect(page.getByText(CLIENTE_NOMBRE)).toBeVisible() + }) + + test('3. crear agente aduanal', async ({ page }) => { + await page.goto('/dashboard/customs_brokers') + await page.waitForLoadState('networkidle') + + await page.getByRole('link', { name: /Nuevo/ }).click() + await expect(page).toHaveURL(/edit/, { timeout: 20000 }) + await page.waitForLoadState('networkidle') + + await fillInput(page, 'input[placeholder="Ej. 550"]', BROKER_KEY) + await fillInput(page, 'input[placeholder="Ej. 3421"]', BROKER_LICENSE) + await fillInput(page, 'input[placeholder="Nombre oficial"]', `Agente E2E ${SUFFIX}`) + + await page.getByRole('button', { name: /Guardar Agente|Actualizar Agente/ }).click() + + await expect(page.getByText(/Agente creado|Agente actualizado/i)).toBeVisible({ timeout: 20000 }) + await expect(page).toHaveURL(/customs_brokers$/, { timeout: 25000 }) + }) + + test('4. crear tipo de cambio', async ({ page }) => { + await page.goto('/dashboard/general_catalogs/exchange-rate') + await page.waitForLoadState('networkidle') + + // Si ya existe TC del día (creado por auth.setup.ts), no intentar duplicar. + const todayCell = page.locator('tbody').getByText(new Date().toLocaleDateString('es-MX')) + if (await todayCell.first().isVisible({ timeout: 2000 }).catch(() => false)) { + return + } + + // El botón se llama "Nuevo Registro"; antes era "Nuevo Tipo de Cambio". + await page.getByRole('button', { name: /Nuevo Registro|Nuevo Tipo de Cambio/ }).click() + await expect(page.getByRole('heading', { name: 'Nuevo Tipo de Cambio' })).toBeVisible() + + await page.locator('#date').fill(TODAY) + await page.waitForTimeout(500) + + await fillInput(page, '#value', '17.5') + + await page.getByRole('button', { name: /^Ok$/ }).click() + await page.getByRole('button', { name: 'Confirmar' }).click() + + await expect(page.getByText(/tipo de cambio creado|tipo de cambio actualizado/i)).toBeVisible({ timeout: 20000 }) + }) + + test('5. crear pedimento', async ({ page }) => { + await page.goto('/dashboard/pedimentos/edit/new') + await page.waitForLoadState('networkidle') + + await expect(page.getByText('Nuevo Pedimento')).toBeVisible({ timeout: 20000 }) + + const prereqModal = page.getByRole('button', { name: /Continuar|Aceptar/ }) + if (await prereqModal.isVisible()) await prereqModal.click() + + await page.waitForTimeout(500) + + // Año — input con id estable + await fillInput(page, '#year', PEDIMENTO_YEAR) + + // Aduana — bits-ui Select, 1er trigger de la fila superior + const triggers = page.locator('[data-select-trigger]') + await triggers.nth(0).click() + await page.waitForTimeout(800) + await page.getByRole('option').first().click() + await page.waitForTimeout(500) + + // Patente — bits-ui Select, 2do trigger + await triggers.nth(1).click() + await page.waitForTimeout(800) + await page.getByRole('option').first().click() + await page.waitForTimeout(500) + + // Número de pedimento — input con id estable + await fillInput(page, '#pedimento_number', PEDIMENTO_NUMBER) + + // Clave — bits-ui Select, 3er trigger + await triggers.nth(2).click() + await page.waitForTimeout(800) + await page.getByRole('option').first().click() + await page.waitForTimeout(500) + + // Tipo de Operación — bits-ui Select, 4to trigger + await triggers.nth(3).click() + await page.waitForTimeout(800) + await page.getByRole('option').first().click() + await page.waitForTimeout(500) + + // Régimen — bits-ui Select, 5to trigger + await triggers.nth(4).click() + await page.waitForTimeout(800) + await page.getByRole('option').first().click() + await page.waitForTimeout(800) + + await page.getByRole('button', { name: /Guardar Todos los Cambios/ }).click() + + await expect(page).toHaveURL(/pedimentos$/, { timeout: 20000 }) + }) + + test('6. crear factura de exportacion', async ({ page }) => { + await page.goto('/dashboard/invoices/edit/new?operation_type=exp') + await page.waitForLoadState('networkidle') + + await expect(page.getByText('Nueva Factura')).toBeVisible({ timeout: 20000 }) + + const prereqModal = page.getByRole('button', { name: /Continuar|Aceptar/ }) + if (await prereqModal.isVisible()) await prereqModal.click() + + await page.waitForTimeout(1500) + + await page.locator('#invoice_number').click() + await page.keyboard.press('Control+A') + await page.locator('#invoice_number').pressSequentially(INVOICE_NUMBER, { delay: 50 }) + + await page.waitForTimeout(1500) + + await page.locator('#invoice_date').fill(TODAY) + await page.waitForTimeout(500) + + // Helper: click en un botón-selector por su placeholder y elige la 1ra opción. + const selectFirstByName = async (btnName: RegExp) => { + await page.getByRole('button', { name: btnName }).first().click() + await page.waitForTimeout(800) + await page.getByRole('option').first().click({ force: true }) + await page.waitForTimeout(600) + } + + // Tipo de factura — sin ID, identificar por placeholder del botón + await selectFirstByName(/^Tipo de factura$/) + + await page.getByRole('tab', { name: /General/ }).click() + await page.waitForTimeout(1500) + + // Selectores con IDs estables (siguen existiendo) + await page.locator('#provider_id').click() + await page.waitForTimeout(500) + await page.getByRole('option').first().click() + + await page.locator('#sold_to_id').click() + await page.waitForTimeout(500) + await page.getByRole('option').first().click() + + await page.locator('#shipped_to_id').click() + await page.waitForTimeout(500) + await page.getByRole('option').first().click() + + // Estos ya no tienen ID — usar el placeholder del botón + await selectFirstByName(/Agente Aduanal Mex/) + await selectFirstByName(/Aduana y Secci[oó]n de Despacho/) + await selectFirstByName(/Clave de R[eé]gimen Aduanero/) + + await page.getByRole('button', { name: /Guardar Todos los Cambios/ }).click() + await expect(page.getByText('Todos los cambios se guardaron correctamente')).toBeVisible({ timeout: 15000 }) + // Guardar numero de factura para tests posteriores + saveShared({ INVOICE_NUMBER }) + }) + + test('7. factura aparece en la lista de exportacion', async ({ page }) => { + const shared = loadShared() + const invoiceNumber = shared.INVOICE_NUMBER || INVOICE_NUMBER + + await page.goto('/dashboard/invoices?operation_type=exp') + await page.waitForLoadState('networkidle') + + await expect(page.locator('main')).toBeVisible() + + await fillInput(page, '#filter-invoice-number, input[placeholder="No. Factura"]', invoiceNumber) + await page.waitForTimeout(2000) + + await expect(page.locator('tbody').getByText(invoiceNumber).first()).toBeVisible({ timeout: 10000 }) + }) + + // TODO(AS-export-partida): El sheet "Nueva Partida" en exportación abre y se cierra + // automáticamente antes de que el test pueda interactuar. Comportamiento estable en + // importación. Probable causa: $effect que cierra el sheet cuando la factura de + // exportación no tiene vinculación a una factura impo previa. + // Requiere fix en item-sheet-fa.svelte; mientras tanto, 8, 10 y 11 quedan en fixme. + test.fixme('8. agregar partida a la factura', async ({ page }) => { + const shared = loadShared() + const invoiceNumber = shared.INVOICE_NUMBER || INVOICE_NUMBER + + await page.goto('/dashboard/invoices?operation_type=exp') + await page.waitForLoadState('networkidle') + + await fillInput(page, '#filter-invoice-number, input[placeholder="No. Factura"]', invoiceNumber) + await expect(page.locator('tbody').getByText(invoiceNumber).first()).toBeVisible({ timeout: 20000 }) + + await page.locator('tbody').getByText(invoiceNumber).first().click() + await page.getByRole('button', { name: /Editar/ }).click() + + await expect(page).toHaveURL(/invoices\/edit\/\d+/, { timeout: 10000 }) + await page.waitForLoadState('networkidle') + + // Ir a pestaña Partidas. La página de export necesita tiempo extra para + // hidratar todas las relaciones (factura, items existentes) antes de Agregar. + await page.getByRole('tab', { name: /Partidas/ }).click() + await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {}) + await page.waitForTimeout(5000) + + // Abrir sheet de nueva partida. En exportación click+focus a veces no dispara; + // usar Enter key tras focus. Reintentar si el sheet no aparece. + const addBtn = page.getByRole('button', { name: /^Agregar Partidas$/ }).first() + const sheetHeading = page.getByRole('heading', { name: /Nueva Partida/i }) + for (let i = 0; i < 5; i++) { + await addBtn.scrollIntoViewIfNeeded() + await addBtn.focus() + await page.keyboard.press('Enter') + if (await sheetHeading.isVisible({ timeout: 4000 }).catch(() => false)) break + await page.waitForTimeout(1500) + } + await sheetHeading.waitFor({ state: 'visible', timeout: 15000 }) + await page.waitForTimeout(2000) + + // Helper para seleccionar en dialog y esperar cierre + async function selectFromDialog(selector: string) { + await page.locator(selector).click() + await page.waitForTimeout(800) + const dialogsBefore = await page.locator('[data-dialog-content]').count() + await page.locator('[data-dialog-content]').last().locator('tbody tr').first().click() + await page.waitForFunction( + (count) => document.querySelectorAll('[data-dialog-content]').length < count, + dialogsBefore, + { timeout: 10000 } + ).catch(() => {}) + await page.waitForTimeout(500) + } + + // Exportacion requiere vincular a una factura de importacion + // El bloque "Factura Impo" aparece en el sheet — click en el input readonly + const sheet = page.locator('[data-slot="sheet-content"]') + const facturaImpoInput = sheet.locator('input[placeholder="Seleccionar factura..."]').first() + if (await facturaImpoInput.isVisible({ timeout: 3000 }).catch(() => false)) { + await facturaImpoInput.click() + await page.waitForTimeout(500) + // InvoiceSelectorModal abre con campo de busqueda + const modalDialog = page.locator('[data-dialog-content]').last() + if (await modalDialog.isVisible({ timeout: 5000 }).catch(() => false)) { + // Buscar la factura de importacion por numero para encontrarla + const searchInput = modalDialog.locator('input[placeholder*="número"], input[placeholder*="numero"], input[type="search"], input[type="text"]').first() + if (await searchInput.isVisible({ timeout: 2000 }).catch(() => false)) { + // Leer numero desde shared file del flujo de importacion + const sharedImp = loadShared() + await searchInput.fill(sharedImp.INVOICE_NUMBER || '') + // Click en buscar si hay boton + const buscarBtn = modalDialog.getByRole('button', { name: /Buscar/i }) + if (await buscarBtn.isVisible({ timeout: 1000 }).catch(() => false)) { + await buscarBtn.click() + } + await page.waitForTimeout(800) + } + // Seleccionar primera fila visible + const firstRow = modalDialog.locator('tbody tr').first() + if (await firstRow.isVisible({ timeout: 5000 }).catch(() => false)) { + await firstRow.click() + await page.waitForTimeout(800) + } else { + // No hay facturas procesadas — cerrar modal y continuar sin vincular + await page.keyboard.press('Escape') + await page.waitForTimeout(500) + } + } + // Seleccionar linea si el input esta habilitado + await page.waitForTimeout(500) + const lineaInput = sheet.locator('#fa_search_line') + if (await lineaInput.isEnabled({ timeout: 3000 }).catch(() => false)) { + await lineaInput.click() + await page.waitForTimeout(800) + const lineDialog = page.locator('[data-dialog-content]').last() + if (await lineDialog.isVisible({ timeout: 3000 }).catch(() => false)) { + const lineBtn = lineDialog.getByRole('button').first() + if (await lineBtn.isVisible({ timeout: 2000 }).catch(() => false)) { + await lineBtn.click() + await page.waitForTimeout(500) + } else { + await page.keyboard.press('Escape') + } + } + } + } + + await selectFromDialog('#clase') + await selectFromDialog('#um') + await selectFromDialog('#pais_origen') + + await page.keyboard.press('Escape') + await page.waitForTimeout(500) + + // Cantidad + await fillInput(page, '#cantidad', '10') + + // Costo unitario + await fillInput(page, '#costo_unitario', '100') + + // Peso neto y bruto — requeridos por el backend + await fillInput(page, '#peso_neto', '5') + await fillInput(page, '#peso_bruto', '6') + + // Descripción en español (textarea) + await fillTextarea(page, '#desc_espanol', `Partida E2E ${SUFFIX}`) + + // Guardar partida — botón "Crear" dentro del sheet + await sheet.getByRole('button', { name: /^(Crear|Guardar)$/i }).click() + await page.waitForTimeout(500) + + // Verificar que la partida aparece en la tabla + await expect(page.locator('tbody').first()).toBeVisible({ timeout: 10000 }) + + // Guardar factura completa + await page.getByRole('button', { name: /Guardar Todos los Cambios/ }).click() + await expect(page.getByText('Todos los cambios se guardaron correctamente')).toBeVisible({ timeout: 15000 }) + }) + + test('9. editar factura existente', async ({ page }) => { + await page.goto('/dashboard/invoices?operation_type=exp') + await page.waitForLoadState('networkidle') + + const shared9 = loadShared() + const invoiceNumber9 = shared9.INVOICE_NUMBER || INVOICE_NUMBER + + await fillInput(page, '#filter-invoice-number, input[placeholder="No. Factura"]', invoiceNumber9) + await expect(page.locator('tbody').getByText(invoiceNumber9).first()).toBeVisible({ timeout: 20000 }) + + await page.locator('tbody').getByText(invoiceNumber9).first().click() + await page.getByRole('button', { name: /Editar/ }).click() + + await expect(page).toHaveURL(/invoices\/edit\/\d+/, { timeout: 10000 }) + await page.waitForLoadState('networkidle') + + await expect(page.getByText(/Factura #/)).toBeVisible() + + await page.getByRole('tab', { name: /General/ }).click() + await page.waitForTimeout(1500) + + await page.locator('#customs_broker_id').click() + await page.waitForTimeout(500) + await page.getByRole('option').first().click() + + await page.locator('#aduana').click() + await page.waitForTimeout(500) + await page.getByRole('option').first().click() + + await page.locator('#document_type').click() + await page.waitForTimeout(500) + await page.getByRole('option').first().click() + + await page.getByRole('button', { name: /Guardar Todos los Cambios/ }).click() + await expect(page.getByText('Todos los cambios se guardaron correctamente')).toBeVisible({ timeout: 15000 }) + }) + + test.fixme('10. editar partida existente', async ({ page }) => { + // Depende de test 8 (sheet inestable en exportación) — ver TODO arriba. + const shared10 = loadShared() + const invoiceNumber10 = shared10.INVOICE_NUMBER || INVOICE_NUMBER + + await page.goto('/dashboard/invoices?operation_type=exp') + await page.waitForLoadState('networkidle') + + await fillInput(page, '#filter-invoice-number, input[placeholder="No. Factura"]', invoiceNumber10) + await expect(page.locator('tbody').getByText(invoiceNumber10).first()).toBeVisible({ timeout: 20000 }) + + await page.locator('tbody').getByText(invoiceNumber10).first().click() + await page.getByRole('button', { name: /Editar/ }).click() + + await expect(page).toHaveURL(/invoices\/edit\/\d+/, { timeout: 10000 }) + await page.waitForLoadState('networkidle') + + // Ir a pestaña Partidas + await page.getByRole('tab', { name: /Partidas/ }).click() + await page.waitForTimeout(500) + + // Abrir sheet de edición — la fila de la partida contiene "E2E01"; el primer botón + // de la cell de acciones es editar. + const partidaRow = page.locator('tbody tr').filter({ hasText: 'E2E01' }).first() + await partidaRow.locator('button').first().click({ force: true, timeout: 10000 }) + await page.waitForTimeout(500) + + // Modificar cantidad + await fillInput(page, '#cantidad', '20') + + // Modificar costo unitario + await fillInput(page, '#costo_unitario', '200') + + // Guardar partida editada — botón "Actualizar" dentro del sheet + const sheetEdit = page.locator('[data-slot="sheet-content"]') + await sheetEdit.getByRole('button', { name: /^(Actualizar|Guardar)$/i }).click() + await page.waitForTimeout(500) + + // Guardar factura completa + await page.getByRole('button', { name: /Guardar Todos los Cambios/ }).click() + await expect(page.getByText('Todos los cambios se guardaron correctamente')).toBeVisible({ timeout: 15000 }) + }) + + test.fixme('11. actualizar factura — verificacion final', async ({ page }) => { + // Depende de tests 8 y 10 (sheet inestable en exportación) — ver TODO arriba. + const shared11 = loadShared() + const invoiceNumber11 = shared11.INVOICE_NUMBER || INVOICE_NUMBER + + await page.goto('/dashboard/invoices?operation_type=exp') + await page.waitForLoadState('networkidle') + + await fillInput(page, '#filter-invoice-number, input[placeholder="No. Factura"]', invoiceNumber11) + await expect(page.locator('tbody').getByText(invoiceNumber11).first()).toBeVisible({ timeout: 20000 }) + + // Seleccionar la fila + await page.locator('tbody tr').first().click() + await page.waitForTimeout(800) + + // Click en boton Actualizar del footer — tiene h-8, gap-1.5 y border + await page.locator('button.h-8:has([class*="lucide-refresh"])').click() + await page.waitForTimeout(1500) + + // Verificar que el proceso se ejecuto — puede ser exito o error de validacion de datos + // El test verifica que el flujo llega hasta el procesamiento, no que los datos sean correctos + await expect( + page.getByText(/actualiz|procesad|exito|validaci|error/i).first() + ).toBeVisible({ timeout: 20000 }) + }) + +}) \ No newline at end of file diff --git a/frontend/e2e/invoice-flow.spec.ts b/frontend/e2e/invoice-flow.spec.ts new file mode 100644 index 0000000..9fb1283 --- /dev/null +++ b/frontend/e2e/invoice-flow.spec.ts @@ -0,0 +1,453 @@ +import { test, expect, type Page } from '@playwright/test' +import * as fs from 'fs' +import { fileURLToPath } from 'url' +import * as path from 'path' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const SHARED_FILE = path.join(__dirname, '.e2e-shared.json') + +function saveShared(data: Record) { + fs.writeFileSync(SHARED_FILE, JSON.stringify(data)) +} + +function loadShared(): Record { + try { + return JSON.parse(fs.readFileSync(SHARED_FILE, 'utf-8')) + } catch { + return {} + } +} + +const SUFFIX = Date.now().toString().slice(-6) +const TODAY = new Date().toISOString().split('T')[0] + +const HOMOCLAVE = SUFFIX.slice(-3).toUpperCase() + +const PROVEEDOR_RFC = `XAXX010101${HOMOCLAVE}` +const PROVEEDOR_NOMBRE = `Proveedor E2E ${SUFFIX}` + +const CLIENTE_RFC = `XBXX010101${HOMOCLAVE}` +const CLIENTE_NOMBRE = `Cliente E2E ${SUFFIX}` + +const BROKER_KEY = `T${SUFFIX.slice(-4)}` +const BROKER_LICENSE = SUFFIX.slice(-4).replace(/^0/, '1') +const INVOICE_NUMBER = `E2E-${SUFFIX}` + +const PEDIMENTO_YEAR = '26' +const PEDIMENTO_OFFICE = '240' +const PEDIMENTO_LICENSE = '3101' +const PEDIMENTO_NUMBER = `${SUFFIX}` + +async function fillInput(page: Page, selector: string, value: string) { + await page.locator(selector).click() + await page.evaluate(({ sel, val }: { sel: string; val: string }) => { + const el = document.querySelector(sel) as HTMLInputElement + if (!el) return + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set + setter?.call(el, val) + el.dispatchEvent(new Event('input', { bubbles: true })) + el.dispatchEvent(new Event('change', { bubbles: true })) + }, { sel: selector, val: value }) + await page.waitForTimeout(300) +} + +async function fillTextarea(page: Page, selector: string, value: string) { + await page.locator(selector).click() + await page.evaluate(({ sel, val }: { sel: string; val: string }) => { + const el = document.querySelector(sel) as HTMLTextAreaElement + if (!el) return + const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set + setter?.call(el, val) + el.dispatchEvent(new Event('input', { bubbles: true })) + el.dispatchEvent(new Event('change', { bubbles: true })) + }, { sel: selector, val: value }) + await page.waitForTimeout(500) +} + +test.describe('Flujo completo — creacion y actualizacion de factura', () => { + + test('1. crear proveedor', async ({ page }) => { + await page.goto('/dashboard/clients_and_providers') + await page.waitForLoadState('networkidle') + + await page.getByRole('link', { name: /Nuevo/ }).click() + await expect(page).toHaveURL(/edit/, { timeout: 10000 }) + await page.waitForLoadState('networkidle') + + await fillInput(page, '#rfc', PROVEEDOR_RFC) + await fillInput(page, '#name', PROVEEDOR_NOMBRE) + + await page.locator('#type').click() + await page.getByRole('option', { name: 'Proveedor' }).click() + + await page.waitForTimeout(500) + + await page.getByRole('button', { name: /Guardar Socio|Actualizar Socio/ }).click() + + await expect(page).toHaveURL(/clients_and_providers$/, { timeout: 15000 }) + await expect(page.getByText(PROVEEDOR_NOMBRE)).toBeVisible() + }) + + test('2. crear cliente', async ({ page }) => { + await page.goto('/dashboard/clients_and_providers') + await page.waitForLoadState('networkidle') + + await page.getByRole('link', { name: /Nuevo/ }).click() + await expect(page).toHaveURL(/edit/, { timeout: 10000 }) + await page.waitForLoadState('networkidle') + + await fillInput(page, '#rfc', CLIENTE_RFC) + await fillInput(page, '#name', CLIENTE_NOMBRE) + + await page.locator('#type').click() + await page.getByRole('option', { name: 'Cliente' }).click() + + await page.waitForTimeout(500) + + await page.getByRole('button', { name: /Guardar Socio|Actualizar Socio/ }).click() + + await expect(page).toHaveURL(/clients_and_providers$/, { timeout: 15000 }) + await expect(page.getByText(CLIENTE_NOMBRE)).toBeVisible() + }) + + test('3. crear agente aduanal', async ({ page }) => { + await page.goto('/dashboard/customs_brokers') + await page.waitForLoadState('networkidle') + + await page.getByRole('link', { name: /Nuevo/ }).click() + await expect(page).toHaveURL(/edit/, { timeout: 20000 }) + await page.waitForLoadState('networkidle') + + await fillInput(page, 'input[placeholder="Ej. 550"]', BROKER_KEY) + await fillInput(page, 'input[placeholder="Ej. 3421"]', BROKER_LICENSE) + await fillInput(page, 'input[placeholder="Nombre oficial"]', `Agente E2E ${SUFFIX}`) + + await page.getByRole('button', { name: /Guardar Agente|Actualizar Agente/ }).click() + + await expect(page.getByText(/Agente creado|Agente actualizado/i)).toBeVisible({ timeout: 20000 }) + await expect(page).toHaveURL(/customs_brokers$/, { timeout: 25000 }) + }) + + test('4. crear tipo de cambio', async ({ page }) => { + await page.goto('/dashboard/general_catalogs/exchange-rate') + await page.waitForLoadState('networkidle') + + // Si ya existe TC del día (creado por auth.setup.ts), no intentar duplicar. + const todayCell = page.locator('tbody').getByText(new Date().toLocaleDateString('es-MX')) + if (await todayCell.first().isVisible({ timeout: 2000 }).catch(() => false)) { + return + } + + // El botón se llama "Nuevo Registro"; antes era "Nuevo Tipo de Cambio". + await page.getByRole('button', { name: /Nuevo Registro|Nuevo Tipo de Cambio/ }).click() + await expect(page.getByRole('heading', { name: 'Nuevo Tipo de Cambio' })).toBeVisible() + + await page.locator('#date').fill(TODAY) + await page.waitForTimeout(500) + + await fillInput(page, '#value', '17.5') + + await page.getByRole('button', { name: /^Ok$/ }).click() + await page.getByRole('button', { name: 'Confirmar' }).click() + + await expect(page.getByText(/tipo de cambio creado|tipo de cambio actualizado/i)).toBeVisible({ timeout: 20000 }) + }) + + test('5. crear pedimento', async ({ page }) => { + await page.goto('/dashboard/pedimentos/edit/new') + await page.waitForLoadState('networkidle') + + await expect(page.getByText('Nuevo Pedimento')).toBeVisible({ timeout: 20000 }) + + const prereqModal = page.getByRole('button', { name: /Continuar|Aceptar/ }) + if (await prereqModal.isVisible()) await prereqModal.click() + + await page.waitForTimeout(500) + + // Año — input con id estable + await fillInput(page, '#year', PEDIMENTO_YEAR) + + // Aduana — bits-ui Select, 1er trigger de la fila superior + const triggers = page.locator('[data-select-trigger]') + await triggers.nth(0).click() + await page.waitForTimeout(800) + await page.getByRole('option').first().click() + await page.waitForTimeout(500) + + // Patente — bits-ui Select, 2do trigger + await triggers.nth(1).click() + await page.waitForTimeout(800) + await page.getByRole('option').first().click() + await page.waitForTimeout(500) + + // Número de pedimento — input con id estable + await fillInput(page, '#pedimento_number', PEDIMENTO_NUMBER) + + // Clave — bits-ui Select, 3er trigger + await triggers.nth(2).click() + await page.waitForTimeout(800) + await page.getByRole('option').first().click() + await page.waitForTimeout(500) + + // Tipo de Operación — bits-ui Select, 4to trigger + await triggers.nth(3).click() + await page.waitForTimeout(800) + await page.getByRole('option').first().click() + await page.waitForTimeout(500) + + // Régimen — bits-ui Select, 5to trigger + await triggers.nth(4).click() + await page.waitForTimeout(800) + await page.getByRole('option').first().click() + await page.waitForTimeout(800) + + await page.getByRole('button', { name: /Guardar Todos los Cambios/ }).click() + + await expect(page).toHaveURL(/pedimentos$/, { timeout: 20000 }) + await expect(page.getByText(/Pedimento creado/i)).toBeVisible({ timeout: 15000 }) + }) + + test('6. crear factura de importacion TEM', async ({ page }) => { + await page.goto('/dashboard/invoices/edit/new?operation_type=imp&invoice_type=TEM') + await page.waitForLoadState('networkidle') + + await expect(page.getByText('Nueva Factura')).toBeVisible({ timeout: 20000 }) + + const prereqModal = page.getByRole('button', { name: /Continuar|Aceptar/ }) + if (await prereqModal.isVisible()) await prereqModal.click() + + await page.waitForTimeout(1500) + + await page.locator('#invoice_number').click() + await page.keyboard.press('Control+A') + await page.locator('#invoice_number').pressSequentially(INVOICE_NUMBER, { delay: 50 }) + + await page.waitForTimeout(1500) + + await page.locator('#invoice_date').fill(TODAY) + await page.waitForTimeout(500) + + await page.getByRole('tab', { name: /General/ }).click() + await page.waitForTimeout(1500) + + await page.locator('#provider_id').click() + await page.waitForTimeout(500) + await page.getByRole('option').first().click() + + await page.locator('#sold_to_id').click() + await page.waitForTimeout(500) + await page.getByRole('option').first().click() + + await page.locator('#shipped_to_id').click() + await page.waitForTimeout(500) + await page.getByRole('option').first().click() + + await page.locator('#customs_broker_id').click() + await page.waitForTimeout(500) + await page.getByRole('option').first().click() + + await page.locator('#aduana').click() + await page.waitForTimeout(500) + await page.getByRole('option').first().click() + + await page.locator('#document_type').click() + await page.waitForTimeout(500) + await page.getByRole('option').first().click() + + await page.getByRole('button', { name: /Guardar Todos los Cambios/ }).click() + await expect(page.getByText('Todos los cambios se guardaron correctamente')).toBeVisible({ timeout: 15000 }) + // Guardar numero de factura para tests posteriores + saveShared({ INVOICE_NUMBER }) + }) + + test('7. factura aparece en la lista de importacion', async ({ page }) => { + const shared = loadShared() + const invoiceNumber = shared.INVOICE_NUMBER || INVOICE_NUMBER + + await page.goto('/dashboard/invoices?operation_type=imp&invoice_type=TEM') + await page.waitForLoadState('networkidle') + + await expect(page.locator('main')).toBeVisible() + + await fillInput(page, '#filter-invoice-number, input[placeholder="No. Factura"]', invoiceNumber) + await page.waitForTimeout(2000) + + await expect(page.locator('tbody').getByText(invoiceNumber).first()).toBeVisible({ timeout: 10000 }) + }) + + test('8. agregar partida a la factura', async ({ page }) => { + const shared = loadShared() + const invoiceNumber = shared.INVOICE_NUMBER || INVOICE_NUMBER + + await page.goto('/dashboard/invoices?operation_type=imp&invoice_type=TEM') + await page.waitForLoadState('networkidle') + + await fillInput(page, '#filter-invoice-number, input[placeholder="No. Factura"]', invoiceNumber) + await expect(page.locator('tbody').getByText(invoiceNumber).first()).toBeVisible({ timeout: 20000 }) + + await page.locator('tbody').getByText(invoiceNumber).first().click() + await page.getByRole('button', { name: /Editar/ }).click() + + await expect(page).toHaveURL(/invoices\/edit\/\d+/, { timeout: 10000 }) + await page.waitForLoadState('networkidle') + + // Ir a pestaña Partidas + await page.getByRole('tab', { name: /Partidas/ }).click() + await page.waitForTimeout(800) + + // Abrir sheet de nueva partida + await page.getByRole('button', { name: /Agregar Partidas/ }).click() + await page.waitForTimeout(1000) + + // Clase — abre un dialog de búsqueda con tabla, scopear al dialog activo + await page.locator('#clase').click() + await page.waitForTimeout(800) + // El dialog de clase tiene data-nested y está encima del sheet + // Scopear al último dialog abierto para evitar que el sheet intercepte + const claseDialog = page.locator('[data-dialog-content]').last() + await claseDialog.locator('tbody tr').first().click() + await page.waitForTimeout(500) + + // Unidad de medida — mismo patron + await page.locator('#um').click() + await page.waitForTimeout(800) + const umDialog = page.locator('[data-dialog-content]').last() + await umDialog.locator('tbody tr').first().click() + await page.waitForTimeout(500) + + // País de origen — abre dialog con tabla igual que clase y UM + await page.locator('#pais_origen').click() + await page.waitForTimeout(800) + const paisDialog = page.locator('[data-dialog-content]').last() + await paisDialog.locator('tbody tr').first().click() + await page.waitForTimeout(500) + + // Cantidad + await fillInput(page, '#cantidad', '10') + + // Costo unitario + await fillInput(page, '#costo_unitario', '100') + + // Peso neto y bruto — requeridos por el backend + await fillInput(page, '#peso_neto', '5') + await fillInput(page, '#peso_bruto', '6') + + // Descripción en español (textarea) + await fillTextarea(page, '#desc_espanol', `Partida E2E ${SUFFIX}`) + + // Guardar partida — botón "Crear" dentro del sheet + const sheet = page.locator('[data-slot="sheet-content"]') + await sheet.getByRole('button', { name: /^(Crear|Guardar)$/i }).click() + await page.waitForTimeout(800) + + // Verificar que la partida aparece en la tabla + await expect(page.locator('tbody').first()).toBeVisible({ timeout: 10000 }) + + // Guardar factura completa + await page.getByRole('button', { name: /Guardar Todos los Cambios/ }).click() + await expect(page.getByText('Todos los cambios se guardaron correctamente')).toBeVisible({ timeout: 15000 }) + }) + + test('9. editar factura existente', async ({ page }) => { + await page.goto('/dashboard/invoices?operation_type=imp&invoice_type=TEM') + await page.waitForLoadState('networkidle') + + const shared9 = loadShared() + const invoiceNumber9 = shared9.INVOICE_NUMBER || INVOICE_NUMBER + + await fillInput(page, '#filter-invoice-number, input[placeholder="No. Factura"]', invoiceNumber9) + await expect(page.locator('tbody').getByText(invoiceNumber9).first()).toBeVisible({ timeout: 20000 }) + + await page.locator('tbody').getByText(invoiceNumber9).first().click() + await page.getByRole('button', { name: /Editar/ }).click() + + await expect(page).toHaveURL(/invoices\/edit\/\d+/, { timeout: 10000 }) + await page.waitForLoadState('networkidle') + + await expect(page.getByText(/Factura #/)).toBeVisible() + + await page.getByRole('tab', { name: /General/ }).click() + await page.waitForTimeout(1500) + + await page.locator('#customs_broker_id').click() + await page.waitForTimeout(500) + await page.getByRole('option').first().click() + + await page.locator('#aduana').click() + await page.waitForTimeout(500) + await page.getByRole('option').first().click() + + await page.locator('#document_type').click() + await page.waitForTimeout(500) + await page.getByRole('option').first().click() + + await page.getByRole('button', { name: /Guardar Todos los Cambios/ }).click() + await expect(page.getByText('Todos los cambios se guardaron correctamente')).toBeVisible({ timeout: 15000 }) + }) + + test('10. editar partida existente', async ({ page }) => { + const shared10 = loadShared() + const invoiceNumber10 = shared10.INVOICE_NUMBER || INVOICE_NUMBER + + await page.goto('/dashboard/invoices?operation_type=imp&invoice_type=TEM') + await page.waitForLoadState('networkidle') + + await fillInput(page, '#filter-invoice-number, input[placeholder="No. Factura"]', invoiceNumber10) + await expect(page.locator('tbody').getByText(invoiceNumber10).first()).toBeVisible({ timeout: 20000 }) + + await page.locator('tbody').getByText(invoiceNumber10).first().click() + await page.getByRole('button', { name: /Editar/ }).click() + + await expect(page).toHaveURL(/invoices\/edit\/\d+/, { timeout: 10000 }) + await page.waitForLoadState('networkidle') + + // Ir a pestaña Partidas + await page.getByRole('tab', { name: /Partidas/ }).click() + await page.waitForTimeout(800) + + // Abrir sheet de edición — la fila de la partida contiene "E2E01"; el primer botón + // de la cell de acciones es editar. + const partidaRow = page.locator('tbody tr').filter({ hasText: 'E2E01' }).first() + await partidaRow.locator('button').first().click({ force: true, timeout: 10000 }) + await page.waitForTimeout(1000) + + // Modificar cantidad + await fillInput(page, '#cantidad', '20') + + // Modificar costo unitario + await fillInput(page, '#costo_unitario', '200') + + // Guardar partida editada — botón "Actualizar" dentro del sheet + const sheetEdit = page.locator('[data-slot="sheet-content"]') + await sheetEdit.getByRole('button', { name: /^(Actualizar|Guardar)$/i }).click() + await page.waitForTimeout(800) + + // Guardar factura completa + await page.getByRole('button', { name: /Guardar Todos los Cambios/ }).click() + await expect(page.getByText('Todos los cambios se guardaron correctamente')).toBeVisible({ timeout: 15000 }) + }) + + test('11. actualizar factura — verificacion final', async ({ page }) => { + const shared11 = loadShared() + const invoiceNumber11 = shared11.INVOICE_NUMBER || INVOICE_NUMBER + + await page.goto('/dashboard/invoices?operation_type=imp&invoice_type=TEM') + await page.waitForLoadState('networkidle') + + await fillInput(page, '#filter-invoice-number, input[placeholder="No. Factura"]', invoiceNumber11) + await expect(page.locator('tbody').getByText(invoiceNumber11).first()).toBeVisible({ timeout: 20000 }) + + // Seleccionar la fila + await page.locator('tbody tr').first().click() + await page.waitForTimeout(800) + + // Click en boton Actualizar del footer — tiene h-8, gap-1.5 y border + await page.locator('button.h-8:has([class*="lucide-refresh"])').click() + await page.waitForTimeout(1500) + + // Verificar resultado + await expect(page.getByText(/actualiz|procesad|exito/i).first()).toBeVisible({ timeout: 20000 }) + }) + +}) \ No newline at end of file diff --git a/frontend/e2e/login.spec.ts b/frontend/e2e/login.spec.ts new file mode 100644 index 0000000..132c7ee --- /dev/null +++ b/frontend/e2e/login.spec.ts @@ -0,0 +1,17 @@ +import { test, expect } from '@playwright/test' + +// El login local fue reemplazado por SSO de Workspace; auth.setup.ts cubre el flujo de auth. +// Este spec mantiene solo verificaciones del dashboard tras el setup. +test.describe('Login', () => { + + test('sesión válida lleva al dashboard', async ({ page }) => { + await page.goto('/dashboard') + await expect(page).toHaveURL(/dashboard/, { timeout: 15000 }) + }) + + test('dashboard muestra encabezado', async ({ page }) => { + await page.goto('/dashboard') + await expect(page.locator('h1')).toBeVisible({ timeout: 15000 }) + }) + +}) \ No newline at end of file diff --git a/frontend/e2e/modules.spec.ts b/frontend/e2e/modules.spec.ts new file mode 100644 index 0000000..e6e3d70 --- /dev/null +++ b/frontend/e2e/modules.spec.ts @@ -0,0 +1,108 @@ +import { test, expect } from '@playwright/test' + +test.describe('Modulos', () => { + + test.describe('Import Invoices', () => { + + test('factura TEM carga sin error', async ({ page }) => { + await page.goto('/dashboard/invoices?operation_type=imp&invoice_type=TEM') + await expect(page).toHaveURL(/invoices/) + await expect(page.locator('main')).toBeVisible() + }) + + test('factura DEF carga sin error', async ({ page }) => { + await page.goto('/dashboard/invoices?operation_type=imp&invoice_type=DEF') + await expect(page).toHaveURL(/invoices/) + await expect(page.locator('main')).toBeVisible() + }) + + }) + + test.describe('Export Invoices', () => { + + test('exportacion carga sin error', async ({ page }) => { + await page.goto('/dashboard/invoices?operation_type=exp') + await expect(page).toHaveURL(/invoices/) + await expect(page.locator('main')).toBeVisible() + }) + + }) + + test.describe('Fixed Catalogs', () => { + + test('carga sin error', async ({ page }) => { + await page.goto('/dashboard/reference_data/code_pedimento_regimens') + await expect(page).toHaveURL(/reference_data/) + await expect(page.locator('main')).toBeVisible() + }) + + }) + + test.describe('General Catalogs', () => { + + test('company information carga sin error', async ({ page }) => { + await page.goto('/dashboard/general_catalogs/company_information') + await expect(page).toHaveURL(/company_information/) + await expect(page.locator('main')).toBeVisible() + }) + + }) + + test.describe('Transportes', () => { + + test('transporters carga sin error', async ({ page }) => { + await page.goto('/dashboard/general_catalogs/transporters') + await expect(page).toHaveURL(/transporters/) + await expect(page.locator('main')).toBeVisible() + }) + + }) + + test.describe('Goods', () => { + + test('fixed asset classes carga sin error', async ({ page }) => { + await page.goto('/dashboard/goods/fixed-asset-classes') + await expect(page).toHaveURL(/goods/) + await expect(page.locator('main')).toBeVisible() + }) + + }) + + test.describe('Settings', () => { + + test('general carga sin error', async ({ page }) => { + await page.goto('/dashboard/settings/general') + await expect(page).toHaveURL(/settings/) + await expect(page.locator('body')).toBeVisible() + }) + + }) + + test.describe('Reportes', () => { + + test('invoices carga sin error', async ({ page }) => { + await page.goto('/dashboard/reports/invoices') + await expect(page).toHaveURL(/reports/) + await expect(page.locator('main')).toBeVisible() + }) + + }) + + test.describe('Logout', () => { + + test('cerrar sesion redirige a login', async ({ page }) => { + await page.goto('/dashboard') + await page.waitForLoadState('networkidle') + // Abrir el dropdown del usuario; el trigger es un Sidebar.MenuButton que + // puede no responder al click central → forzar con teclado. + const userBtn = page.getByRole('button').filter({ hasText: '@' }).first() + await userBtn.focus() + await page.keyboard.press('Enter') + // El menú está parcialmente en inglés ("Log out"). + await page.getByRole('menuitem', { name: /Log out|Cerrar sesión/i }).click({ timeout: 10000 }) + await expect(page).toHaveURL(/(login|workspace\.aduanasoft\.com)/, { timeout: 15000 }) + }) + + }) + +}) \ No newline at end of file diff --git a/frontend/e2e/navigation.spec.ts b/frontend/e2e/navigation.spec.ts new file mode 100644 index 0000000..10697f7 --- /dev/null +++ b/frontend/e2e/navigation.spec.ts @@ -0,0 +1,79 @@ +import { test, expect } from '@playwright/test' + +test.describe('Navegacion', () => { + + test.beforeEach(async ({ page }) => { + await page.goto('/dashboard') + }) + + test('dashboard carga con saludo', async ({ page }) => { + await expect(page.locator('h1')).toBeVisible() + }) + + test('header muestra nombre de la empresa', async ({ page }) => { + await page.waitForLoadState('networkidle') + // El nombre exacto depende de la empresa activa; aceptar cualquier nombre no vacío en el botón del sidebar + await expect(page.locator('[data-sidebar]').getByRole('button').first()) + .toBeVisible({ timeout: 10000 }) + }) + + test.describe('Menu lateral', () => { + // El sidebar tiene links directos y grupos plegables que cargan según permisos. + // Verificamos que aparezcan los textos en alguna parte del menú (botón o link). + + test('tiene Bitácora en el menu', async ({ page }) => { + await page.waitForLoadState('networkidle') + await expect(page.getByRole('link', { name: 'Bitácora' }).or( + page.getByRole('button', { name: 'Bitácora' }) + ).first()).toBeVisible({ timeout: 10000 }) + }) + + test('tiene Agentes Aduanales en el menu', async ({ page }) => { + await page.waitForLoadState('networkidle') + await expect(page.getByRole('link', { name: 'Agentes Aduanales' })).toBeVisible({ timeout: 10000 }) + }) + + test('Fracciones aparece en el menu', async ({ page }) => { + await page.waitForLoadState('networkidle') + await expect(page.getByText('Fracciones').first()).toBeVisible({ timeout: 10000 }) + }) + + test('Pedimentos aparece en el menu', async ({ page }) => { + await page.waitForLoadState('networkidle') + // "Pedimentos" aparece como botón top-level del sidebar (no como sub-items plegados). + await expect(page.getByRole('button', { name: /^Pedimentos$/ }).first()) + .toBeVisible({ timeout: 10000 }) + }) + + }) + + test.describe('Modulos accesibles', () => { + // Navegamos directo por URL — el menú lateral es dinámico y no garantiza link visible. + + test('Bitácora carga sin error', async ({ page }) => { + await page.goto('/dashboard/audit_logs') + await expect(page).toHaveURL(/audit/) + await expect(page.locator('h1')).toBeVisible() + }) + + test('Agentes Aduanales carga sin error', async ({ page }) => { + await page.goto('/dashboard/customs_brokers') + await expect(page).toHaveURL(/customs/) + await expect(page.locator('h1')).toBeVisible() + }) + + test('Fraction Sitar carga sin error', async ({ page }) => { + await page.goto('/dashboard/general_catalogs/tariff-fractions/sitar') + await expect(page).toHaveURL(/tariff-fractions/) + await expect(page.locator('main')).toBeVisible() + }) + + test('Pedimentos carga sin error', async ({ page }) => { + await page.goto('/dashboard/reference_data/code_pedimento_regimens') + await expect(page).toHaveURL(/reference_data/) + await expect(page.locator('main')).toBeVisible() + }) + + }) + +}) \ No newline at end of file diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..e78afbd --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,43 @@ +import prettier from 'eslint-config-prettier'; +import { fileURLToPath } from 'node:url'; +import { includeIgnoreFile } from '@eslint/compat'; +import js from '@eslint/js'; +import svelte from 'eslint-plugin-svelte'; +import { defineConfig } from 'eslint/config'; +import globals from 'globals'; +import ts from 'typescript-eslint'; +import svelteConfig from './svelte.config.js'; + +const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url)); + +export default defineConfig( + includeIgnoreFile(gitignorePath), + js.configs.recommended, + ...ts.configs.recommended, + ...svelte.configs.recommended, + prettier, + ...svelte.configs.prettier, + { + languageOptions: { + globals: { ...globals.browser, ...globals.node } + }, + rules: { // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects. + // see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors + "no-undef": 'off' } + }, + { + files: [ + '**/*.svelte', + '**/*.svelte.ts', + '**/*.svelte.js' + ], + languageOptions: { + parserOptions: { + projectService: true, + extraFileExtensions: ['.svelte'], + parser: ts.parser, + svelteConfig + } + } + } +); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..d02723d --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12723 @@ + + + + + + Anexo76 - Gestión de Comercio Exterior + + + + + + +

Anexo76

Gestión de Comercio Exterior

Bienvenido a Anexo76

Plataforma SaaS para gestión de comercio exterior conforme a Anexos 24, 30 y 22 del SAT. + Ideal para maquilas, empresas IMMEX y agentes aduanales.

Registrarse

Características principales

Multi-tenant

Arquitectura híbrida con BD compartida o dedicada según necesidades

Seguridad

Autenticación con Keycloak y control de acceso basado en roles

Licencias

Planes flexibles desde Free hasta Enterprise con features personalizadas

© 2025 Anexo76. Desarrollado para la industria de comercio exterior mexicana.

+ + +
+ + diff --git a/frontend/messages/en.json b/frontend/messages/en.json new file mode 100644 index 0000000..9ce878d --- /dev/null +++ b/frontend/messages/en.json @@ -0,0 +1,2094 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "hello_world": "Hello, {name} from en!", + "exchange_rate_error_title": "Exchange Rate Error", + "dashboard": { + "greeting_morning": "Good morning", + "greeting_afternoon": "Good afternoon", + "greeting_evening": "Good evening", + "team_suffix": ", team.", + "operational_summary": "Operational Summary — Annexes 22/24/30", + "management_system": "Foreign Trade Management System", + "loading": "Loading...", + "update": "Update", + "operations_distribution": "Operations Distribution", + "top_clients": "Top 5 Clients", + "top_providers": "Top 5 Providers", + "by_invoice_number": "By number of invoices", + "quick_access": "Quick Access", + "most_used_modules": "Most Used Modules", + "invoices": "Invoices", + "manage_invoices": "Manage invoices", + "clients_and_providers": "Clients and Providers", + "manage_contacts": "Manage contacts", + "goods": "Goods", + "product_catalog": "Product catalog", + "reference_data": "Reference Data", + "sat_catalogs": "SAT Catalogs", + "total_documents": "Total Documents", + "total_contacts": "Total Contacts", + "active_items_stat": "Active Items", + "no_active_company": "No active company selected", + "load_error": "Error loading dashboard", + "pedimentos": "Pedimentos", + "clients": "Clients", + "providers": "Providers", + "pending_approvals": "Pending", + "operations_trend": "Operations Trend", + "monthly_evolution": "Monthly evolution of operations", + "recent_activity": "Recent Activity", + "latest_operations": "Latest operations registered in the system", + "no_recent_activity": "No recent activity", + "records_suffix": "records", + "just_now": "Just now", + "ago_suffix": "Ago", + "min_short": "min", + "h_short": "h", + "d_short": "d", + "no_data_available": "No data available", + "data_will_appear_here": "Data will appear here once registered", + "operations_in": "operations in", + "more_than_one_month_needed": "The graph will appear with more than one month of data", + "average_per_month": "Average / month", + "maximum": "Maximum", + "total": "Total", + "operations": "operations", + "previous_month": "prev. month", + "operations_breakdown": "Operations Breakdown", + "ops_short": "ops", + "operations_will_appear_here": "Operations will appear here once registered", + "no_data_available_short": "No data available" + }, + "exchange_rate": { + "new_title": "New Exchange Rate", + "edit_title": "Edit Exchange Rate", + "required_title": "Exchange Rate Required", + "required_description": "To proceed with saving, it is necessary to register the official exchange rate for this date.", + "applicable_date": "Applicable Date", + "exchange_rate_label": "Exchange Rate (MXN/USD)", + "required_badge": "Required", + "consulting": "Consulting...", + "consult_dof": "Consult DOF", + "example_suffix": "Eg.", + "cancel": "Cancel", + "ok": "Ok", + "confirm_title": "Are you sure?", + "confirm_description_create": "The exchange rate with value {value} will be created for the day {date}.", + "confirm_description_update": "The exchange rate with value {value} will be updated for the day {date}.", + "confirm_action": "Confirm", + "toast_dof_success": "Exchange rate obtained from DOF: {value}", + "toast_dof_error": "Could not obtain data from DOF", + "toast_dof_service_error": "Error consulting DOF service", + "error_no_company": "No company selected", + "error_date_required": "Date is required", + "error_value_required": "Exchange rate is required", + "error_value_positive": "Exchange rate must be a value greater than 0", + "toast_create_success": "Exchange rate created successfully", + "toast_update_success": "Exchange rate updated successfully" + }, + "multi_currency": { + "new_title": "New Multi-Currency Exchange Rate", + "edit_title": "Edit Multi-Currency Exchange Rate", + "error_currency_required": "Currency code is required", + "error_date_required": "Publication date is required", + "currency_label": "Currency", + "currency_help": "Currency code (FK).", + "country_label": "Country", + "country_help": "Country M3 key (FK).", + "date_label": "Date", + "date_help": "Saved as integer (YYYYMMDD).", + "factor_label": "Factor", + "saving": "Saving...", + "update": "Update", + "create": "Create" + }, + "sidebar": { + "dashboard": "Dashboard", + "help_center": "System Manuals", + "management_label": "Management", + "bulk_upload": { + "title": "Bulk Uploads", + "entry": "CSV Import" + }, + "reference_data": { + "title": "Fixed Catalogs", + "codes_pedimento_regimen": "Pedimento and Regime Codes", + "containers": "Containers", + "countries": "Countries", + "currency_types": "Currency Types", + "customs_sections": "Customs Sections", + "customs_warehouses": "Customs Warehouses", + "incoterms": "Incoterms", + "document_types_digitization": "Document types for digitization", + "invoice_types": "Invoice Types", + "material_types": "Material Types", + "payment_methods": "Payment Methods", + "pedimento_codes": "Pedimento Codes", + "pedimento_regimes": "Pedimento Regimes", + "sectors": "Sectors", + "states": "States", + "transportation_modes": "Transportation Methods", + "transportation_types": "Transportation Types", + "valuation_methods": "Valuation Methods", + "configuracion": "Settings", + "general": "General", + "licencia": "License", + "usuarios": "Users", + "ayuda": "Help" + }, + "general_catalogs": { + "title": "General Catalogs", + "company_information": "Company Information", + "packages": "Packages", + "concepts": "Concepts", + "classification": "Classification", + "identifiers": "Identifiers", + "incoterms": "Incoterms", + "inpc": "I.N.P.C", + "fixed_legends": "Fixed Legends", + "seals": "Seals", + "valuation_methods": "Valuation Methods", + "countries": "Countries", + "ports": "Ports", + "unit_measures": "Units of Measure", + "um_customs_mex": "Units of Measure - Mexican Customs", + "um_customs_ame": "Units of Measure - American Customs", + "um_ace": "Units of Measure - ACE", + "um_oma": "Units of Measure - OMA", + "conversions": "Conversions", + "equivalences": "Equivalences", + "exchange_rates": "Exchange Rates", + "currency_types": "Currency Types", + "multi_currency": "Multi Currency", + "invoice_types": "Invoice Types", + "electronic_signatures": "Electronic Signatures", + "billing_errors": "Billing Errors", + "customs_warehouses": "Customs Warehouses", + "locations": "Locations", + "doda": "DODA", + "packing_list": "Packing List", + "prevalidators": "Prevalidators", + "electronic_notices": "Electronic Notices", + "back_flush": "Back Flush", + "crossing_notice": "Crossing Notice", + "customs_broker_concepts": "Customs Broker Concepts" + }, + "fractions": { + "title": "Fractions", + "sitar": "Fraction Sitar", + "sitar_seventh_amendment": "Fraction Sitar - Seventh Amendment", + "sitar_us": "Fraction Sitar US", + "american": "Fraction US", + "canadian": "Fraction Canadian", + "historical": "Fraction Historical", + "sectors": "Sectors" + }, + "goods": { + "title": "Goods", + "classes": "Classes", + "parts": "Parts", + "fda_codes": "FDA Codes" + }, + "pedimentos": { + "title": "Pedimentos", + "pedimento_management": "Pedimento Management", + "pedimento_codes": "Pedimento Codes", + "customs_regimes": "Customs Regimes", + "payment_methods": "Payment Methods", + "customs_sections": "Customs Sections", + "anexo_22_app_31": "Anexo 22 App 3" + }, + "import_invoices": { + "title": "Import Invoices", + "temporary": "Temporary", + "definitive": "Definitive", + "mexican_purchases": "Mexican Purchases", + "regime_change": "Regime Change", + "repair": "Repair" + }, + "export_invoices": { + "title": "Export Invoices", + "exportation": "Exportation", + "repair": "Repair" + }, + "export": { + "title": "Exportation", + "catalog": "Export Catalog", + "repair": "Repair", + "manifest": "Manifest", + "proforma": "Proforma", + "reports": "Reports", + "used_materials": "Used Materials Module", + "destruction": "Destruction", + "special_processes": "Special Processes" + }, + "clients_and_providers": "Clients and Providers", + "customs_brokers": "Customs Brokers", + "audit_logs": "Audit Logs", + "audit_logs_title": "Audit Logs", + "audit_logs_description": "Audit trail of operations and background task (Celery) status.", + "audit_logs_tab_bitacora": "Audit trail", + "audit_logs_tab_tasks": "Background tasks", + "audit_logs_tab_files": "File manager", + "audit_logs_files_title": "File manager", + "audit_logs_files_root": "Files root", + "audit_logs_files_refresh": "Refresh", + "audit_logs_files_list_title": "Contents", + "audit_logs_files_error_prefix": "Error:", + "audit_logs_files_col_name": "Name", + "audit_logs_files_col_size": "Size", + "audit_logs_files_col_modified": "Modified", + "audit_logs_files_col_actions": "Actions", + "audit_logs_files_loading": "Loading files...", + "audit_logs_files_empty": "No files or folders found in this location.", + "audit_logs_files_download": "Download", + "despacho": { + "title": "Dispatch", + "digitalizacion": "Digitization", + "doda": "DODA" + }, + "doda_alta": { + "title": "DODA", + "subtitle": "Customs Clearance Declaration", + "new": "New", + "refresh": "Refresh", + "table_title": "DODAs", + "col_integration_number": "Integration No.", + "col_patent": "Patent", + "col_status": "Status", + "col_dispatch_customs": "Dispatch Customs", + "col_operation_type": "Operation Type", + "col_actions": "Actions", + "action_alta_doda": "DODA Filing", + "action_alta_pita": "PITA Filing", + "action_edit": "Edit", + "action_delete": "Delete", + "action_new": "New DODA", + "progress_title": "Processing DODA filing...", + "progress_success": "DODA filing completed successfully.", + "progress_error": "Error in DODA filing.", + "eligibility_error": "DODA does not meet the requirements for filing.", + "eligibility_checking": "Checking eligibility...", + "empty": "No DODAs", + "loading": "Loading...", + "search_placeholder": "Search:", + "confirm_delete": "Are you sure you want to delete this DODA?", + "delete_success": "DODA deleted successfully", + "delete_error": "Error deleting DODA", + "delete_missing_company": "Select a company", + "delete_select_one": "Select exactly one DODA from the list", + "delete_not_found": "Could not locate the DODA. Select the row again and retry", + "filter_integration_number": "Integration No.", + "filter_patent": "Patent", + "filter_status": "Status", + "filter_operation_type": "Operation Type", + "action_generar": "Submit", + "action_export_excel": "Report by date range", + "action_export_pedimentos": "DODA report", + "export_pedimentos_success": "DODA report generated.", + "export_pedimentos_error": "Could not generate the DODA report.", + "export_excel_title": "Export DODA list", + "export_excel_subtitle": "Filter by DODA date (stored as YYYYMMDD).", + "export_excel_badge": "DODA CATALOG", + "export_report_heading": "General report by date range", + "export_fecha_inicio": "Start date", + "export_fecha_final": "End date", + "export_julian_label": "Use Julian (numeric) date in Excel file.", + "export_report_generar": "Generate", + "export_date_from": "From", + "export_date_to": "To", + "export_format": "File format", + "export_date_mode": "Date/time in file", + "export_date_mode_formatted": "Formatted (DD/MM/YYYY and time)", + "export_date_mode_raw": "Numeric (raw YYYYMMDD)", + "export_download": "Download", + "export_cancel": "Close", + "export_excel_success": "File generated.", + "export_excel_error": "Could not generate the file.", + "export_no_data": "No DODAs in the selected date range. Widen the range or try other dates.", + "export_excel_invalid_dates": "Enter from and to dates." + }, + "digitalizacion": { + "title": "Digitization", + "subtitle": "Digitized Documents Catalog", + "new": "New", + "refresh": "Refresh", + "table_title": "Digitized documents", + "col_consecutivo": "Consecutive", + "col_tipo_documento": "Document Type", + "col_e_document": "E-Document", + "col_fecha": "Date", + "col_num_operacion_vu": "VU Operation No.", + "col_actions": "Actions", + "form_e_document": "E-Document", + "form_num_operacion": "Operation No.", + "form_tipo_documento": "Document Type", + "form_archivo_digitalizado_en": "Digitized in", + "form_fecha": "Date", + "form_agente_aduanal": "Customs Broker", + "form_pedimento": "Entry", + "form_nombre_archivo": "File name", + "digitalizar_title": "Digitize Document", + "digitalizar_subtitle": "Send document to Ventanilla Única", + "digitalizar_file_label": "File", + "digitalizar_rfc_consulta": "RFC Query", + "digitalizar_clave_documento": "Document Key", + "progress_title": "Digitalizing document...", + "progress_step": "Step", + "progress_success": "Digitalization completed successfully.", + "progress_download_acuse": "Download Receipt", + "action_digitalizar": "Digitalize", + "action_download_zip": "Download ZIP", + "action_acuse": "Receipt", + "action_envio_xml": "Envío XML", + "action_respuesta_xml": "Respuesta XML", + "action_consulta_envio_xml": "Consulta Envío XML", + "action_consulta_respuesta_xml": "Consulta Respuesta XML", + "action_edit": "Edit", + "action_delete": "Delete", + "empty": "No digitized documents", + "loading": "Loading...", + "search_placeholder": "Search:", + "confirm_delete": "Are you sure you want to delete this document?" + }, + "client_provider_type": { + "client_indicator": "C", + "provider_indicator": "P", + "both_indicator": "B" + }, + "nav_user": { + "profile": "Profile", + "settings": "Settings", + "logout": "Logout" + }, + "transports": { + "title": "Transportation", + "transporters": "Carriers", + "drivers": "Drivers", + "trailers": "Trailers", + "vehicles": "Vehicles", + "vehicle_transport_types": { + "ar": "Armored Truck", + "au": "Automobiles", + "bt": "Box Truck", + "bu": "Bus", + "bv": "Beverage Truck (Refer or not)", + "by": "Bicycle", + "co": "Construction Vehicle (general)", + "ev": "Emergency Vehicle (general)", + "fe": "Ferry", + "fm": "Farm Tractor", + "gb": "Garbage Truck", + "mc": "Motorcycle", + "oc": "Other", + "pm": "Pick-up Truck w/camper", + "pn": "Panel Truck", + "pu": "Pickup Truck", + "pv": "Passenger", + "rv": "Recreation Vehicle (RV)", + "tr": "Semi Tracker", + "tv": "Van" + } + }, + "reports": { + "title": "Reports", + "invoices": "Impo/Expo Invoices", + "downloaded_parts": "Downloaded Parts", + "expiration": "Expiration Report" + }, + "settings": { + "general": "General" + }, + "doda_form": { + "shortcuts_scope": "DODA form", + "title_new": "New DODA", + "title_edit": "Edit DODA", + "description_catalog": "Catalogs · DODA", + "tab_general": "General", + "tab_seals_sat": "Seals and SAT", + "shortcuts_hint": "Alt+1/2 · Ctrl+S save · Esc cancel", + "btn_cancel": "Cancel", + "btn_save": "Save", + "btn_saving": "Saving...", + "btn_save_changes": "Save changes", + "btn_create_doda": "Create DODA", + "btn_accept": "OK", + "card_broker_customs": "Customs agent and office", + "card_transport": "Transport", + "card_control": "Control and dispatch", + "card_sat_chain": "Original chain and signatures (SAT)", + "label_responsible": "Broker", + "label_patent": "Patent", + "label_dispatch": "Dispatch office", + "label_section_es": "E/S section", + "label_operation_type": "Operation type", + "label_transporter": "Carrier", + "label_transport_id": "Transport ID", + "label_caat": "CAAT", + "label_doda_date": "DODA date", + "label_status": "Status", + "label_dispatch_type": "Dispatch type", + "label_unique_badge": "Unique badge", + "label_integration_num": "Integration No.", + "label_transaction_num": "Transaction No.", + "label_fast_id": "Fast ID", + "label_last_user": "Last user", + "label_original_chain": "Original chain", + "label_serial_cert": "Serial (certificate)", + "label_uuid_cp": "Carta porte UUID", + "label_electronic_sig": "Electronic signature", + "label_sat_cert": "SAT certificate", + "label_sat_chain": "Original SAT chain", + "ph_aga": "AGA key", + "ph_0000": "0000", + "ph_000": "000", + "ph_select": "Select", + "ph_plate": "Plate / vehicle ID", + "ph_dash": "—", + "ph_yyyymmdd": "YYYYMMDD", + "ph_badge_pita": "N/A — PITA", + "ph_badge_num": "Badge no.", + "ph_example_container": "E.g. 53056", + "op_import": "I — Import", + "op_export": "E — Export", + "type_pita": "PITA", + "type_doda": "DODA", + "vu_checking": "Verifying agent VU DODA…", + "vu_incomplete": "VU DODA incomplete: agent needs .cer, .key, and DODA FIEL password.", + "vu_complete": "VU DODA complete for API submission.", + "badge_required_hint": "Required for DODA filing API.", + "pedimentos": "Pedimentos", + "lines": "lines", + "containers": "Containers", + "american_pedimentos": "U.S. pedimentos", + "seals_block_title": "Seals — total in DODA: {n} / 8", + "seals_help": "Select a container. Maximum 8 seals per DODA (SCAII).", + "seals_select_container": "Select a container in the table to view or edit its seals.", + "container_no_id_warning": "Container not saved on server. Enter value, press Save; new containers are sent and reloaded with id for seals.", + "container_line_info": "Container:", + "seal_on_line": "seal(s) on this line", + "line_word": "Line", + "btn_add_seal": "Add seal", + "btn_seal_delete": "Delete", + "seals_empty_line": "No seals on this container.", + "col_line": "Line", + "col_auth_patent": "Auth. patent", + "col_document": "Document", + "col_remesa": "Shipment", + "col_cove": "COVE", + "col_umc": "UMC", + "col_cash_usd": "Cash USD", + "col_diff_usd": "Difference USD", + "col_dta_niu": "DTA NIU", + "col_art7": "Art. 7", + "col_container": "Container", + "col_seals": "Seals", + "col_seal_value": "Seal", + "col_american_type": "Type", + "col_american_ped": "U.S. pedimento", + "col_pedimento_only": "U.S. pedimento", + "yes": "Yes", + "no": "No", + "child_empty": "No rows. “New” to add.", + "child_new": "New", + "child_edit": "Edit", + "child_delete": "Delete", + "modal_container_new": "New container", + "modal_container_edit": "Edit container", + "modal_container_desc": "Enter the container value for the DODA declaration.", + "label_container_value": "Container value", + "modal_seals_in_container": "Seals in container", + "seal_modal_title": "Containers > Seal", + "seal_modal_desc": "Enter the seal value for the selected container.", + "label_seal": "Seal", + "ph_seal": "Seal value", + "american_modal_title": "U.S. pedimento", + "american_modal_desc": "Enter type and value of the U.S. pedimento.", + "label_american_type_short": "U.S. type", + "label_american_value": "U.S. pedimento", + "ph_american_value": "U.S. pedimento value", + "line_label": "Line:", + "select_type": "Select type", + "american_cat_6": "AMERICAN PEDIMENTO", + "american_cat_7": "SELF-DECLARATION", + "american_cat_8": "NOT PRESENT", + "err_american_tipo_required": "U.S. pedimento type is required.", + "err_american_tipo_import": "U.S. pedimento type is not valid for import (must be 1, 2, 3, 4, or 5).", + "err_american_tipo_export": "U.S. pedimento type is not valid for export (must be 6, 7, or 8).", + "err_american_op_undefined": "Set operation type (I/E) before validating the U.S. pedimento.", + "err_company": "Select a company", + "err_responsible": "Broker is required", + "err_patent": "Patent is required", + "err_transport": "Transport ID is required. Select a vehicle.", + "err_badge": "Unique badge number is required for DODA filing.", + "err_vu_wait": "Wait for agent VU DODA check to finish, then try again.", + "err_vu_config": "The customs agent does not have full VU DODA config (.cer, .key, DODA FIEL password).", + "err_min_containers": "Add at least one container for API submission.", + "err_american_new_lines": "Enter the U.S. pedimento value for each new line.", + "err_save": "Error saving", + "toast_saved": "Changes saved successfully.", + "toast_created": "DODA created successfully.", + "load_error": "Could not load DODA", + "warn_vu_incomplete": "This DODA’s agent does not have full VU DODA (.cer, .key, DODA FIEL password).", + "warn_vu_fetch": "Could not validate the agent’s VU settings.", + "warn_broker_select": "Selected agent has incomplete VU DODA. Configure in Customs agents before generating.", + "seal_save_first": "Save the DODA before managing seals.", + "seal_pick_container": "Select a container in the table.", + "seal_not_persisted": "This container is not on the server yet. Save the DODA and reload.", + "seal_empty": "Seal cannot be empty.", + "seal_max": "DODA already has the maximum 8 seals.", + "seal_add_err": "Error adding seal", + "seal_delete_err": "Error removing seal", + "pedimento_remove_blocked": "Cannot remove pedimentos already saved on the server here.", + "container_delete_err": "Error deleting container", + "american_delete_err": "Error deleting U.S. pedimento", + "container_update_err": "Error updating container", + "american_cannot_edit_persisted": "To change saved U.S. pedimentos, remove and add again.", + "err_american_value": "Enter the U.S. pedimento value.", + "err_american_type_or_value": "Enter type and/or U.S. pedimento value.", + "err_containers_max": "A DODA can have at most 4 containers.", + "err_container_empty": "Container value cannot be empty.", + "err_container_not_found": "Container to edit not found.", + "pedimento_selector_title": "Containers > Seal", + "list_page_subtitle": "Manage your Customs Operation Documents (DODA)", + "list_btn_new": "New DODA", + "list_card_title": "DODA list", + "list_ph_folio": "Folio", + "list_ph_patent": "Patent", + "list_filter_status_ph": "Status", + "list_filter_status_all": "All", + "list_filter_op_import": "Import", + "list_filter_op_export": "Export", + "list_filter_op": "Operation", + "list_filter_op_all": "All", + "list_btn_clear": "Clear", + "list_showing": "Showing {a} of {b} records", + "list_active_filters": "Active filters: {n}", + "list_btn_edit": "Edit", + "list_btn_print": "Print", + "list_toast_reload_error": "Error reloading data", + "list_elig_error_prefix": "Error checking eligibility: ", + "list_elig_not_meet": "This DODA does not meet the filing requirements.", + "list_alta_error_prefix": "Error sending DODA filing: ", + "list_print_error": "Error generating DODA PDF", + "list_alta_complete": "DODA filing completed successfully", + "list_shortcuts_scope": "DODA list", + "list_col_folio": "Folio", + "list_col_doda_date": "DODA date", + "list_col_desp": "Cstm.", + "list_col_patent": "Patent", + "list_col_pedimentos": "Pedimento(s)", + "list_col_remesas": "Shipment(s)", + "list_col_integracion": "Integration", + "list_col_trans": "Trans. no.", + "list_col_id_transport": "Transport ID", + "list_col_caat": "CAAT", + "list_col_user": "User", + "list_col_status": "Status", + "list_loading_more": "Loading more...", + "list_scroll_for_more": "Scroll to load more", + "list_confirm_delete": "Are you sure you want to delete this DODA record?", + "list_toast_delete_ok": "DODA deleted successfully", + "list_toast_delete_err": "Error deleting DODA", + "list_filter_i": "I — Import", + "list_filter_e": "E — Export", + "list_no_results": "No results." + } + }, + "invoice_list": { + "skip_to_actions": "Go to invoice actions", + "header": { + "title": "Invoices", + "description": "Manage system invoices" + }, + "titles": { + "base": "INVOICE CATALOG", + "import": "IMPORT", + "export": "EXPORT", + "import_temporal": "TEMPORARY IMPORT", + "import_definitive": "DEFINITIVE IMPORT", + "import_mexican": "MEXICAN PURCHASES", + "import_regime_change": "REGIME CHANGE AND REGULARIZATION", + "import_repair": "IMPORT REPAIR", + "export_definitive": "DEFINITIVE EXIT", + "export_repair": "REPAIR" + }, + "filters": { + "operation_label": "Operation Type", + "operation_all_option": "Operation: All", + "invoice_type_label": "Invoice Type", + "invoice_type_all_option": "Invoice: All", + "invoice_number_placeholder": "Invoice No.", + "year_start_placeholder": "Start year", + "year_end_placeholder": "End year", + "active_filters": "Active filters" + }, + "actions": { + "parameters": "Settings", + "new_invoice": "New Invoice", + "refresh": "Refresh", + "reports": "Reports", + "more_actions": "More Actions", + "downloads": "Downloads", + "other_actions": "Other Actions", + "cancel": "Cancel", + "continue": "Continue", + "generate_cove": "Generate COVE", + "close": "Close" + }, + "card": { + "invoice_list_title": "Invoice List" + }, + "summary": { + "showing": "Showing", + "of": "of", + "records": "records" + }, + "operation_types": { + "all": "All", + "import": "Import", + "export": "Export" + }, + "cove_dialog": { + "title": "Generate COVE", + "description_prefix": "Select the recipient email for invoice", + "recipient_label": "Recipient email", + "destination": "COVE destination", + "select_email": "Select an email", + "fallback_email": "It will be sent to the email of the user who generated the invoice", + "search_email": "Search email...", + "loading_emails": "Loading available emails...", + "no_emails": "No emails available for COVE.", + "selected_badge": "Selected" + }, + "progress": { + "title_pdf": "Generating Invoice PDF", + "title_consolidated": "Generating Consolidated Report", + "title_descargo": "Generating FIFO Report", + "title_packing_list": "Generating Packing List", + "title_winsaai": "Generating WINSAAI Report", + "title_process_invoice": "Processing invoice", + "title_revert_invoice": "Reverting invoice", + "title_validate_cove": "Validating data for COVE", + "complete_processed": "Invoice processed successfully", + "complete_reverted": "Invoice reverted successfully", + "complete_cove_validation": "COVE validation completed", + "complete_default": "Process completed" + }, + "steps": { + "load_invoice": "Loading invoice", + "validate_invoice_data": "Validating invoice data", + "review_classes_exchange_rate": "Reviewing classes and exchange rate", + "calculate_item_values": "Calculating item values", + "validate_items": "Validating items", + "validate_rule8_quotas": "Validating Rule Eight quotas", + "update_totals": "Updating totals", + "validate_invoice_status": "Validating invoice status", + "verify_item_balances": "Verifying item balances", + "confirm_changes": "Confirming changes" + }, + "dialogs": { + "revert_title_export": "Revert Export Invoice", + "revert_title_import": "Revert Import Invoice", + "revert_description_intro": "The invoice", + "revert_description_warning": "This operation will revert the balance/discharge records generated when processing the invoice.", + "revert_description_question": "Do you want to continue?", + "winsaai_title": "Customs and Inventory Control System", + "winsaai_description_intro": "Invoice", + "winsaai_of_type": "of type", + "winsaai_description_process": "has been assigned to WINSAAI File Generation.", + "winsaai_description_question": "Do you want to Continue or Cancel?" + }, + "footer": { + "toolbar_aria": "Invoice actions", + "invoice_pdf": "Invoice PDF", + "invoice_csv": "Invoice CSV", + "consolidated": "Consolidated", + "consolidated_notice": "Consolidated Notice", + "packing_list": "Packing List", + "four_copies_rem": "4 REM Copies", + "descargo_peps": "FIFO Discharge", + "transferencia_electronica": "Electronic Transfer", + "interface_vu": "VU Interface", + "vu_options_keyboard": "VU options (keyboard)", + "vu_consult": "Consult", + "vu_addenda": "Addenda", + "vu_cove_receipt": "COVE Receipt", + "vu_massive_cove": "Mass COVE", + "cons_sed": "SED Consult", + "encomienda": "Commission", + "fact_mex_cons": "Mex Invoice Cons", + "fact_mex_ord_cat": "Mex Invoice Ord Cat", + "export_sia": "Export SIA", + "interface": "Interface", + "process_update": "Update", + "unprocess": "Revert", + "view_details": "View Details", + "customs_broker_interface": "Customs Broker Interface", + "edit": "Edit", + "delete": "Delete" + }, + "submenu": { + "consult_soon": "VU Consult - Coming soon", + "addenda_soon": "VU Addenda - Coming soon", + "massive_cove_soon": "Mass COVE - Coming soon", + "generate_invoice_csv_soon": "Generate Invoice CSV - Coming soon", + "four_copies_soon": "4 REM Copies - Coming soon", + "cons_sed_soon": "SED Consult - Coming soon", + "encomienda_soon": "Commission - Coming soon", + "fact_mex_cons_soon": "Mex Consolidated Invoice - Coming soon", + "fact_mex_ord_cat_soon": "Mex Invoice Capture Order - Coming soon", + "export_sia_soon": "Export SIA - Coming soon", + "interface_soon": "Interface - Coming soon" + }, + "recipients": { + "company_vu_email": "Company VU email", + "company_main_email": "Company main email", + "company_industrial_1": "Industrial email 1", + "company_industrial_2": "Industrial email 2", + "company_description": "Company {name}", + "single_window_email": "Single window email", + "main_email": "Main email", + "company_user_email": "Company user - {email}", + "my_email": "My email", + "authenticated_user": "Authenticated user - {email}", + "load_error": "Could not load available emails for COVE", + "no_configured": "No emails configured for COVE" + }, + "toasts": { + "select_invoice_for_cove": "Select an invoice to generate COVE", + "no_company_selected": "No company selected", + "session_expired_reloading": "Session expired. Reloading page...", + "load_more_error": "Error loading more data", + "apply_filters_error": "Error applying filters", + "reload_data_error": "Error reloading data", + "download_start_error": "Could not start download", + "consolidated_download_start_error": "Could not start consolidated download", + "calculating_peps": "Calculating FIFO assignment...", + "peps_calculation_error_prefix": "Error calculating FIFO: {error}", + "peps_calculation_completed": "FIFO calculation completed", + "peps_report_start_error": "Could not start FIFO report download", + "aviso_consolidado_start_error": "Could not start Consolidated Notice download", + "packing_list_start_error": "Could not start Packing List download", + "fast_interface_import_only": "Quick interface is only available for Import invoices", + "customs_broker_interface_start_error": "Could not start Customs Broker Interface generation", + "pdf_download_success": "PDF downloaded successfully", + "invoice_processed_success": "Invoice processed successfully", + "worker_error_prefix": "Worker reported an error: {error}", + "task_result_process_error": "Error processing task result", + "select_invoice_to_edit": "Select an invoice to edit", + "no_table_rows": "No rows in the table", + "select_invoice_for_reports": "Select an invoice for reports", + "select_invoice_for_more_actions": "Select an invoice for more actions", + "select_invoice_to_revert": "Select an invoice to revert", + "select_invoice_for_details": "Select an invoice to view details", + "select_invoice": "Select an invoice", + "select_at_least_one_invoice_to_delete": "Select at least one invoice to delete", + "select_invoice_for_pdf": "Select an invoice to download PDF", + "select_invoice_for_consolidated": "Select an invoice to download consolidated report", + "select_invoice_to_change_status": "Select an invoice to change status", + "update_status_error_prefix": "Error trying to {action} invoice: {error}", + "status_action_update": "update", + "status_action_revert": "revert", + "status_updated_success": "Invoice updated successfully", + "status_reverted_success": "Invoice reverted successfully", + "update_status_unexpected_error": "Unexpected error while changing status", + "select_invoice_to_process": "Select an invoice to process", + "process_start_error_prefix": "Error starting process: {error}", + "process_start_error": "Could not start process", + "revert_start_error_prefix": "Error starting revert: {error}", + "revert_start_error": "Could not start revert", + "select_recipient_email_for_cove": "Select an email to send COVE", + "cove_eligibility_error_prefix": "Could not validate COVE eligibility: {error}", + "cove_requirements_not_met": "Invoice does not meet COVE generation requirements", + "cove_verification_error": "Could not verify whether invoice can generate COVE", + "cove_start_error_prefix": "Error starting COVE generation: {error}", + "cove_start_error": "Could not start COVE generation", + "validation_extra_more": "\n...and {count} more", + "validation_error_count": "{count} validation error(s):\n{preview}{extra}", + "cove_external_queued_default": "COVE invoice started in Single Window. Use task_id to check status." + } + }, + "invoice_table": { + "no_results": "No results.", + "loading_more": "Loading more...", + "scroll_to_load_more": "Scroll to load more", + "processed": "Processed", + "pending": "Pending", + "operation": "Operation", + "operation_import": "Import", + "operation_export": "Export", + "invoice_type": "Invoice Type", + "invoice_number": "Invoice No.", + "pedimento_18": "Pedimento 18", + "remesa": "Remesa", + "invoice_date": "Invoice Date", + "pedimento_code": "Pedimento Code", + "document_type": "Doc Type", + "total_items": "Total Items", + "currency": "Currency", + "currency_type": "Currency Type", + "weight_type": "Weight Type", + "mixed": "Mixed", + "related_doc": "Related Doc", + "yes": "Yes", + "no": "No", + "not_available_short": "N/A" + }, + "invoice_selectors": { + "identifier_catalog": { + "title": "Select Identifier", + "description": "Search and select an identifier from catalog (Appendix 8).", + "search_placeholder": "Search by code or description...", + "column_code": "Code", + "column_description": "Description", + "column_level": "Level", + "empty": "No identifiers found." + }, + "valuation_method": { + "title": "Select Valuation Method", + "description": "Search and select a valuation method from the list.", + "search_placeholder": "Search by code or description...", + "column_code": "Code", + "column_description": "Description", + "empty": "No valuation methods found." + }, + "location": { + "title": "Location catalog (machinery and equipment)", + "no_company_selected": "No company selected", + "load_error": "Error loading locations", + "required_key": "Key is required", + "save_error": "Error saving", + "key_label": "Key *", + "key_placeholder": "Location key", + "location_label": "Location", + "location_placeholder": "Name or description", + "department_label": "Department", + "responsible_label": "Responsible", + "observations_label": "Observations", + "optional_placeholder": "Optional", + "back_to_list": "Back to list", + "save": "Save", + "search_placeholder": "Search by key or location...", + "register_new": "Register new location", + "column_key": "Key", + "column_location": "Location", + "no_results": "No results found", + "cancel": "Cancel" + }, + "tariff_fraction": { + "title": "SITAR FRACTIONS CATALOG - SCAII", + "search_label": "Searching:", + "search_placeholder": "Search by fraction, description, NICO...", + "column_key": "Key", + "column_fraction": "Fraction", + "column_nico": "NICO", + "column_description": "Description", + "column_umt": "U.M.T", + "column_adv_impo": "Adv. Impo", + "column_adv_expo": "Adv. Expo", + "column_dof": "DOF", + "column_aplica_ieps": "Applies IEPS", + "loading": "Loading fractions...", + "empty": "No fractions available", + "cancel": "Cancel" + }, + "us_tariff_fraction": { + "no_company_selected": "No company selected", + "load_error_prefix": "Error: {error}", + "no_records_info": "No registered US tariff fractions were found", + "connection_error_prefix": "Connection error: {error}", + "title": "Select US Tariff Fraction", + "description": "Select tariff fraction (HTS) from catalog.", + "search_placeholder": "Search by code or description...", + "loading_catalog": "Loading catalog...", + "no_results": "No fractions found.", + "column_code": "Code (HTS)", + "column_description": "Description", + "records_found": "{count} records found", + "cancel": "Cancel" + }, + "invoice_selector_modal": { + "no_active_company": "No active company has been selected", + "search_error": "Error searching invoices", + "title_export": "Export Invoices", + "title_import": "Import Invoices ({regimen})", + "description_export": "Select an invoice from catalog to link it to the item.", + "description_import": "Select a processed import invoice for regimen {regimen}.", + "search_placeholder": "Search by invoice number...", + "searching_button": "Searching...", + "search_button": "Search", + "searching_available": "Searching available invoices...", + "no_invoices": "No invoices found", + "try_other_filter": "Try another invoice number or filter", + "processed_badge": "Processed", + "pedimento_label": "Pedimento", + "no_date": "No date", + "not_available_short": "N/A", + "select": "Select", + "total_found": "Total: {count} invoices found", + "close": "Close" + }, + "port_selector": { + "title": "Select Port (Customs/Section)", + "description": "Search and select a customs section from the list.", + "search_placeholder": "Search by code or name...", + "column_code": "Code", + "column_name": "Name / Section", + "loading": "Loading customs sections...", + "empty": "No results found", + "cancel": "Cancel" + }, + "manifest_selector": { + "title": "Select Manifest", + "description": "Search and select an export manifest to link to this invoice.", + "search_placeholder": "Search by number...", + "search_button": "Search", + "searching": "Searching manifests...", + "column_number": "Manifest Number", + "column_description": "Description", + "empty": "No results found" + } + }, + "invoice_edit": { + "new_title": "New Invoice", + "edit_title": "Edit Invoice", + "new_description": "Enter the new invoice data", + "edit_description": "Modify the invoice data", + "draft_badge": "Draft", + "saved_success": "All changes were saved successfully", + "invoice_number_prefix": "Number:", + "edit_details": "Edit the invoice details", + "page_invoice_prefix": "Invoice #", + "page_default_values_loaded_prefix": "Default values loaded for {invoiceType}", + "page_save_error_prefix": "Error saving the invoice", + "page_save_changes_error": "Error saving changes", + "page_console_hint": "Check the console for more details", + "page_session_expired": "Session expired. Reloading page...", + "tabs": { + "general": "General", + "compliance": "Compliance", + "financials": "Financials", + "observations": "Observations", + "items": "Items", + "others": "Others", + "continuation": "Cont." + }, + "form": { + "operation_type_label": "Operation Type *", + "operation_type_placeholder": "Select type", + "operation_type_import": "Import", + "operation_type_export": "Export", + "invoice_number_label": "Invoice Number", + "invoice_number_placeholder": "Invoice number", + "invoice_type_label": "Invoice Type", + "invoice_type_placeholder": "Invoice type", + "no_company_selected": "No company selected", + "exchange_rate_required": "Exchange rate is required (Financials tab)", + "exchange_rate_positive": "Exchange rate must be greater than 0 (Financials tab)", + "save_error": "Error saving", + "loading_defaults_prefix": "Default values loaded for", + "pedimento_pending": "Pedimento pending?", + "pedimento_label": "Pedimento", + "pedimento_placeholder": "Select pedimento...", + "remesa_label": "Remesa", + "invoice_number_label_short": "Invoice No.", + "invoice_date_label_exp": "Date", + "invoice_date_label_mex": "Entry date", + "invoice_date_label_default": "Invoice date", + "emission_date_label": "Emission date", + "iva_factor_label": "IVA factor", + "alternate_invoice_label": "Alternate invoice", + "project_number_label": "Project Number", + "project_number_placeholder": "Project number", + "purchase_order_label": "Purchase Order", + "purchase_order_placeholder": "Purchase order", + "invoice_date_label": "Invoice date", + "validation": { + "trailer_required": "Trailer is required when Transport Type is different from None.", + "missing_fields": "The following fields are required:", + "check_transport_data": "Check transport and logistics data", + "save_error": "Error saving changes" + }, + "traffic_light_status_label": "Traffic light", + "traffic_light_status_placeholder": "Traffic light status", + "observation_es_label": "Observations (Spanish)", + "observation_es_placeholder": "Observations in Spanish", + "observation_en_label": "Observations (English)", + "observation_en_placeholder": "Observations in English", + "remesa_placeholder": "Remesa number", + "aduana_label": "Customs", + "aduana_placeholder": "Customs code", + "customs_broker_label": "Customs broker", + "customs_broker_placeholder": "Customs broker ID", + "provider_label": "Provider", + "provider_placeholder": "Provider ID", + "edocument_label": "E-Document", + "edocument_placeholder": "E-document number", + "is_mixed_label": "Mixed operation", + "currency_placeholder": "MXN, USD, etc.", + "exchange_rate_placeholder": "Exchange rate", + "value_mn_label": "MN value", + "value_mn_placeholder": "Value in local currency", + "value_me_label": "ME value", + "value_me_placeholder": "Value in foreign currency", + "customs_value_mn_label": "Customs value MN", + "customs_value_mn_placeholder": "Customs value in MN", + "freight_label": "Freight", + "freight_placeholder": "Freight cost", + "insurance_label": "Insurance", + "insurance_placeholder": "Insurance cost", + "iva_mn_label": "IVA MN", + "iva_mn_placeholder": "IVA in MN", + "total_quantity_label": "Total quantity", + "total_quantity_placeholder": "Total quantity", + "gross_weight_label": "Gross weight", + "gross_weight_placeholder": "Gross weight", + "net_weight_label": "Net weight", + "net_weight_placeholder": "Net weight", + "bundle_count_label": "Bundle count", + "bundle_count_placeholder": "Bundle count", + "update_button": "Update", + "create_button": "Create" + }, + "general": { + "pedimento_section": "Pedimento data", + "pedimento_date_from": "Date from:", + "pedimento_date_to": "Date to:", + "pedimento_code": "Code:", + "pedimento_regimen": "Regime:", + "clients_suppliers_broker": "Clients - Suppliers - Customs Broker", + "provider_header_supplier": "Supplier", + "provider_header_exporter": "Exporter", + "sold_to_header_consignado": "Consigned to", + "sold_to_header_vendido": "Sold to", + "sold_to_header_exportado": "Exported to", + "sold_to_header_importador": "Importer", + "shipped_to_header_enviado": "Sent to", + "shipped_to_header_transferido": "Transferred to", + "shipped_to_header_donado": "Donated to", + "shipped_to_header_importador": "Importer", + "shipped_by_header_enviado_por": "Sent by", + "shipped_by_header_destinatario": "Recipient", + "shipped_by_header_vendido_por": "Sold by", + "shipped_by_header_notificar": "Notify to", + "select_header_placeholder": "Select header...", + "select_placeholder": "Select...", + "select_broker_placeholder": "Select...", + "broker_mex_label": "Mex. Customs Broker:", + "broker_usa_label": "US Customs Broker:", + "currency_weight_section": "Currency Type - Net and Gross Weights", + "exchange_rate": "Exchange rate:", + "currency_foreign": "Foreign (USD)", + "currency_local": "Local (MXN)", + "currency_manual": "Manual entry", + "currency_label": "Currency:", + "weight_type_label": "Weight type:", + "weight_type_kgs": "Kilograms (kg)", + "weight_type_lbs": "Pounds (lb)", + "manifest_number_label": "Manifest no.:", + "manifest_placeholder": "Manifest...", + "transport_section": "Transporter", + "transport_label": "Transporter:", + "transport_key_label": "Transport key:", + "transport_type_label": "Transport type:", + "trailer_label": "Trailer:", + "driver_label": "Driver:", + "iva_label": "VAT:", + "customs_label": "Customs and dispatch section:", + "document_type_label": "Customs regime code:", + "select_transporter_placeholder": "Select transporter...", + "select_vehicle_placeholder": "Select vehicle...", + "select_driver_placeholder": "Select driver...", + "select_trailer_placeholder": "Select trailer...", + "select_customs_placeholder": "Select customs office...", + "select_regimen_placeholder": "Select regime...", + "choose_transporter_first": "Choose transporter first...", + "no_data": "No data", + "no_drivers_for_transporter": "No drivers for this transporter", + "no_regimens_for_operation": "No regimes for type", + "choose_operation_first": "Select operation type first", + "transport_none": "None", + "transport_type_transport": "Transport", + "transport_type_box": "Box", + "transport_type_licence_plates": "Plates", + "transport_type_truck": "Truck", + "transport_type_vessel": "Vessel", + "transport_type_rail_barge": "Rail barge", + "transport_type_container": "Container", + "transport_type_airplane": "Airplane", + "transport_type_gondola": "Gondola", + "transport_type_flatbed": "Flatbed", + "signature_label": "Electronic signature:", + "general_info": "General information" + }, + "page": { + "saving_all_changes": "Saving all changes...", + "save_all_changes": "Save All Changes", + "cancel": "Cancel" + }, + "observations": { + "mexican_observation": "Mexican invoice observations:", + "bilingual_observation": "Mexican and bilingual invoice observations:", + "textarea_placeholder": "Write your observations here.", + "fixed_legend": "Fixed legend:", + "selected_legend_prefix": "Key", + "select_legend_placeholder": "Select legend...", + "add_to_observations": "Add to observations", + "american_observation": "US invoice observations:", + "identifiers_title": "Identifiers", + "first_label": "First:", + "second_label": "Second:", + "key_placeholder": "Key...", + "complements_title": "Complements", + "one_label": "1:", + "two_label": "2:", + "office_label": "Office:", + "incrementables_title": "Incrementables:", + "freight_label": "Freight:", + "insurance_label": "Insurance:", + "packaging_label": "Packaging:", + "other_increments_label": "Other incr.:", + "other_deductibles_label": "Other deduct.:", + "seal_number_label": "Seal Number:", + "movement_type_label": "Movement Type:", + "alternate_invoice_label": "Alternate Invoice:", + "proforma_number_label": "Proforma Number:", + "subdivision_label": "Subdivision:", + "yes": "Yes", + "no": "No", + "acts_as_cd_label": "Acts as CD:", + "incoterm_label": "Incoterm:", + "select_placeholder": "Select...", + "valuation_method_label": "Valuation Method:", + "mixed_label": "Mixed?", + "seal_count_label": "Seal Count:", + "delivery_title": "Delivery Data", + "delivered_label": "Delivered", + "received_by_label": "Received by:", + "delivery_date_label": "Delivery Date:", + "rule_parties_label": "Rule 3.1.21 Parties II", + "status_comment_label": "Status Comment:", + "status_comment_placeholder": "Status comment", + "related_docs_label": "Docs Relation ID:", + "electronic_signature_label": "Electronic Signature:", + "authorized_person_label": "Attorney/Authorized Person:", + "contingency_mode_label": "Contingency Mode", + "cove_label": "COVE:", + "operation_number_label": "Operation No.:", + "adendas_label": "Addenda(s):", + "vu_observations_label": "VU Observations:", + "load_info": "Load Info.", + "entry_exit_date_label": "Entry/Exit Date:", + "payment_date_label": "Payment Date:", + "certificate_number_label": "Certificate Number:", + "enclosure_label": "Enclosure:", + "alternate_flags_title": "Alternate Invoice & Flags", + "valuation_method_placeholder": "Select...", + "mixed_label_short": "Mixed?", + "errors_title": "Billing Errors", + "line": "Line", + "key": "Key", + "description": "Description", + "no_errors": "No errors registered", + "insert": "Insert", + "edit": "Edit", + "delete": "Delete" + }, + "others": { + "transport_mode_label": "Transport Mode:", + "select_mode_placeholder": "Select mode", + "print_stamp_label": "Print stamp for value less than 2500 USD", + "mixed_label": "Mixed?", + "yes": "Yes", + "no": "No", + "master_bol_label": "Master BOL Number:", + "guide_number_label": "Guide Number:", + "shipment_number_label": "Shipment Number:", + "option_iv18_label": "IV 18 Option:", + "select_option_placeholder": "Select option", + "delivery_title": "Delivery Data", + "delivered_label": "Delivered", + "received_by_label": "Received by:", + "delivery_date_label": "Delivery Date:", + "rule_3121_label": "Rule 3.1.21 Parties II", + "status_comment_label": "Status Comment:", + "status_comment_placeholder": "Status comment", + "related_docs_label": "Docs Relation ID:", + "electronic_signature_label": "Electronic Signature:", + "authorized_person_label": "Attorney/Authorized Person:", + "contingency_mode_label": "Contingency Mode", + "cove_label": "COVE:", + "operation_number_label": "Operation No.:", + "adendas_label": "Addenda(s):", + "vu_observations_label": "VU Observations:", + "load_info": "Load Info.", + "entry_exit_date_label": "Entry/Exit Date:", + "payment_date_label": "Payment Date:", + "certificate_number_label": "Certificate Number:", + "electronic_signature_2_label": "Electronic Signature:", + "errors_title": "Billing Errors", + "line": "Line", + "key": "Key", + "description": "Description", + "no_errors": "No errors registered", + "insert": "Insert", + "edit": "Edit", + "delete": "Delete" + }, + "items": { + "unsaved_invoice_title": "Invoice not saved", + "unsaved_invoice_description": "You must save the invoice before adding items.", + "loaded_more_items": "Loading more items...", + "deleted": "Item deleted", + "delete_failed": "Could not delete the item", + "no_data_to_save": "No data to save", + "required_fields": "Fill in the required fields (Class or Description)", + "no_active_company": "There is no active company ID. Make sure you have a company selected.", + "no_invoice_id": "There is no invoice ID. The invoice must be saved before adding items.", + "update_failed": "Could not update the item", + "updated": "Item updated", + "create_failed": "Could not create the item", + "created": "Item created", + "save_error": "Error saving", + "saved_to_template": "Item saved to template", + "save_invoice_first": "Save the invoice first to use templates.", + "use_template_description": "Select a predefined template to load its items.", + "refresh": "Refresh", + "search_templates_placeholder": "Search templates...", + "loading": "Loading...", + "template_applied": "Template applied", + "apply_template_error": "Error applying template", + "template_saved": "Template saved", + "save_template_error": "Error saving template", + "title": "Invoice Items", + "subtitle": "Load items, create templates, or apply them without leaving this view.", + "use_template": "Use template", + "create_template": "Create template", + "add_items": "Add items", + "cancel": "Cancel", + "applying": "Applying...", + "apply_template": "Apply Template", + "create_template_dialog_title": "Create template", + "create_template_dialog_description": "Save the current items as a reusable template to inject into other items.", + "template_name_label": "Template Name", + "template_name_placeholder": "E.g. Standard parts package", + "template_description_label": "Description", + "template_description_placeholder": "Describe what this template is for...", + "template_items_count": "items/lines", + "template_items_title": "Template items", + "add_item_line": "Add Item/Line", + "template_table_hash": "#", + "template_table_description": "Description", + "template_table_quantity": "Qty.", + "template_table_actions": "Actions", + "template_empty": "Use the \"Add Item/Line\" button to define the template contents.", + "no_description": "No description", + "no_description_short": "No description available.", + "no_description_available": "No description available.", + "no_templates_found": "No templates found", + "select_template_to_view": "Select a template to view its details", + "created_label": "Created", + "item_description": "Item Description", + "quantity_short": "Qty.", + "quantities": "Quantities:", + "template_empty_items": "This template does not contain items.", + "imported_quantity": "Imported Qty.", + "reference": "Ref:", + "saving": "Saving...", + "save_template": "Save template", + "column_line": "Line", + "column_impo_invoice": "Impo Invoice", + "column_ps": "P/S", + "column_class": "Class", + "column_part_number": "Part Number", + "column_description": "Description", + "column_has_subitem": "Contains Sub-item", + "column_main_item": "Main Item", + "column_class_description": "Class Description", + "column_um": "U.M.", + "column_preference": "Preference", + "column_quantity": "Quantity", + "column_actions": "Actions", + "no_items_available": "No items available", + "showing_lines": "Showing {displayed} of {total} lines", + "spanish_description_label": "Description in Spanish:", + "select_row_to_view_description": "Select a row to view the description.", + "bultos": "Bundles:", + "imported": "Imported:", + "net_weight": "Net weight:", + "gross_weight": "Gross weight:", + "import_values_title": "Import values:", + "dollars": "Dollars:", + "pesos": "Pesos:", + "capture_value": "Capture Value:", + "customs_value_short": "Customs:", + "error_panel_title_with_count": "Errors ({count})", + "warning_panel_title": "Warning", + "error_panel_fallback_message": "We couldn't save your changes. Review the information and try again.", + "error_panel_clear": "Clear", + "error_panel_column_type": "Type", + "error_panel_column_field": "Field", + "error_panel_column_message": "Message", + "error_panel_empty_field": "—", + "error_panel_dismiss_row_aria": "Dismiss this error", + "error_panel_toggle_details_aria": "Show or hide error details", + "inline_notice_close_aria": "Dismiss notice" + } + }, + "invoice_item_fa": { + "item_sheet": { + "tab_general": "General", + "tab_identifiers": "Identifiers", + "not_available_short": "N/A" + }, + "repair": { + "generate_discharge": "Generate Discharge?", + "export_invoice_label": "Expo Invoice", + "export_line_label": "Expo Line", + "type_search_label": "Search Type", + "import_type_label": "Import Type:", + "import_invoice_label": "Import Invoice", + "line_label": "Line", + "loading_line": "Loading...", + "search_placeholder": "Select...", + "temporal": "TEM (Temporary)", + "definitive": "DEF (Definitive)", + "loading_item_data": "Loading item data...", + "close": "Close", + "cancel": "Cancel", + "select_line_title": "Select line", + "import_title": "Import items", + "import_description": "Select a line with available balance to perform the discharge.", + "loading_invoice_items": "Loading invoice items...", + "no_balance": "No balance available", + "no_balance_description": "There are no lines with balance in this invoice to discharge.", + "no_description": "No description" + }, + "main_data": { + "legend": "Main Data", + "quantity": "Quantity", + "unit_cost": "Unit Cost", + "total_value": "Total Value", + "tariff_type": "Tariff Type" + }, + "packages": { + "legend": "PACKAGES", + "quantity": "Quantity", + "package_code": "Package Code", + "weight": "Weight", + "description": "Description", + "weights": "WEIGHTS", + "net": "Net", + "gross": "Gross", + "space": "Space", + "permit_number": "Permit No.", + "page_region": "Page/Region", + "american_fraction": "US Fraction", + "brand": "Brand", + "model": "Model", + "purchase_order": "Purchase Order" + }, + "summary": { + "general_data": "GENERAL DATA", + "return_quantity_subitems": "RETURN QUANTITY SUB-ITEMS", + "temporary": "Temporary", + "replacement_or_change": "Replacement or Change", + "definitive": "Definitive", + "returned_values": "Returned Values", + "weights_kilos": "WEIGHTS (KILOS)", + "weights_pounds": "WEIGHTS (POUNDS)", + "net": "Net", + "gross": "Gross", + "costs_values": "COSTS AND VALUES", + "dollars": "(Dollars)", + "pesos": "(Pesos)", + "cost": "Cost", + "value": "Value", + "customs_value": "Customs Value", + "capture_cost": "Capture Cost", + "capture_value": "Capture Value" + }, + "continuation": { + "tax_paid": "TAX PAID", + "yes": "Yes", + "no": "No", + "general_info": "General information", + "transport_number_type": "Transport number/type:", + "vehicle_data": "Vehicle data:", + "is_rail": "Is rail?", + "bill_number": "Bill of lading no.:", + "guide_count": "Shipping guide count (BL):", + "destination_origin": "Destination/Origin:", + "destination_origin_placeholder": "FRANJA FRONT.", + "is_mixed": "Mixed?", + "entry_port": "Entry port:", + "export_reason": "Export reason:", + "reason_sold": "Sold", + "reason_not_sold": "Not sold", + "reason_other": "Other", + "payment_terms": "Payment terms:", + "handling_fees": "Handling fees:", + "reviewed_equipment": "Equipment reviewed", + "subdivision": "Subdivision", + "acts_as_cd": "Acts as CD", + "pedimento_arrived": "Pedimento arrived", + "billing_errors": "Billing errors", + "error_line": "Line", + "error_key": "Key", + "error_description": "Description", + "no_errors": "No errors registered", + "insert": "Insert", + "edit": "Edit", + "delete": "Delete", + "traffic_light": "Traffic light", + "green_mx": "Green MX", + "green_usa": "Green USA", + "red_mx": "Red MX", + "red_usa": "Red USA", + "cfdi_data_title": "CFDI DATA", + "cfdi_uuid_label": "CFDI UUId:", + "cfdi_pdf_label": "CFDI Path PDF:", + "cfdi_xml_label": "CFDI Path XML:", + "payment_method": "Payment Method", + "igi_amount": "IGI Amount", + "dollars": "DOLLARS", + "igi_payment_method": "IGI Payment Method", + "has_fda_code": "Has FDA Code", + "has_certificate_of_origin": "Has Certificate of Origin?", + "certificate_number": "Certificate of Origin No.", + "end_date": "End Date", + "machinery_equipment_location": "Machinery and equipment location", + "location_variable": "Location variable", + "military_equipment_enable": "Enable if Item Contains Military Equipment", + "own_equipment": "Own Equipment", + "omit_annex31": "Omit Annex 31", + "lot": "Lot", + "entry_number": "Entry No.", + "eighth_rule_permit": "Eighth Rule Permit", + "eighth_rule_fraction": "Eighth Rule Fraction", + "line": "Line", + "consider_a31": "Consider in A31", + "extra_description_spanish": "Extra Description in Spanish" + }, + "configuration": { + "is": "Is", + "item": "Item", + "subitem": "Subitem", + "contains_subitems": "Contains Sub-Items", + "yes": "Yes", + "main_item_number": "Main Item Number", + "main_item_number_placeholder": "Enter main item number", + "description_spanish": "Description in Spanish", + "description_english": "Description in English" + }, + "labeling": { + "legend": "Labeling & Valuation", + "label_number": "Label Number", + "label_type": "Label Type", + "observations": "Observations", + "observations_placeholder": "Labeling observations...", + "assets_series": "Assets / Series", + "asset_number_short": "Asset Num", + "actions_short": "Act.", + "asset_number": "Asset Number", + "cancel": "Cancel", + "save": "Save" + }, + "identifiers": { + "asset_number": "Asset Number", + "asset_tag_title": "Asset Tag" + }, + "dialogs": { + "countries_load_error": "Error loading countries", + "states_load_error": "Error loading states", + "packages_load_error": "Error loading packages", + "units_load_error": "Error loading units of measure", + "payment_methods_load_error": "Error loading payment methods" + }, + "invoice_item_inv": { + "edit_title": "Edit Item", + "add_title": "Add New Item", + "edit_description": "Modify inventory fields and save changes.", + "add_description": "Fill in the new inventory item information.", + "line_prefix": "Line", + "required_fields_hint": "Fields marked with * are required.", + "tab_general": "General", + "tab_classification": "Classification", + "tab_quantities": "Quantities", + "tab_other": "Other", + "invoice_info_title": "Invoice Information", + "invoice_unsaved_warning": "This invoice has not been saved yet. Items will be associated when you save the invoice.", + "invoice_id": "Invoice ID:", + "operation_type": "Operation Type:", + "invoice_number": "Invoice Number:", + "system": "System:", + "class_label": "Class", + "select_class_placeholder": "Select a class", + "quantity_label": "Quantity", + "unit_label": "U.M.", + "select_unit_placeholder": "Select U.M.", + "unit_cost_label": "Unit Cost", + "country_label": "Country of Origin", + "select_country_placeholder": "Select country", + "fraction_label": "Fraction", + "select_fraction_placeholder": "Select fraction", + "tariff_type_label": "Tariff Type", + "reference_number_label": "Reference Number", + "purchase_order_label": "Purchase/Sales Order", + "warehouse_label": "Warehouse", + "location_label": "Location", + "description_es_label": "Description (Spanish)", + "description_es_placeholder": "Description in Spanish", + "description_en_label": "Description (English)", + "description_en_placeholder": "Description in English", + "sku_label": "SKU", + "sku_placeholder": "Product SKU code", + "batch_label": "Batch", + "batch_placeholder": "Batch number", + "classification_fraction_label": "Tariff Fraction", + "fraction_digits_placeholder": "8 digits", + "product_type_label": "Product Type", + "product_type_placeholder": "Raw material, finished product, etc.", + "material_type_label": "Material Type", + "material_type_placeholder": "Metal, plastic, etc.", + "product_code_label": "Product Code", + "product_code_placeholder": "Internal code", + "country_origin_label": "Country of Origin", + "country_code_placeholder": "Country code", + "merchandise_category_label": "Merchandise Category", + "merchandise_category_placeholder": "Category", + "quantity_tab_label": "Quantity", + "unit_of_measure_label": "Unit of Measure", + "unit_of_measure_placeholder": "PCS, KG, M, etc.", + "zero_placeholder": "0", + "decimal_placeholder": "0.00", + "net_weight_label": "Net Weight (KG)", + "gross_weight_label": "Gross Weight (KG)", + "unit_cost_usd_label": "Unit Cost (USD)", + "total_value_label": "Total Value (USD)", + "packages_label": "Number of Packages", + "package_type_label": "Package Type", + "package_type_placeholder": "Box, pallet, etc.", + "imported_quantity_label": "Imported Quantity", + "remaining_quantity_label": "Remaining Quantity", + "brand_label": "Brand", + "brand_placeholder": "Product brand", + "expiration_date_label": "Expiration Date", + "production_date_label": "Production Date", + "min_stock_label": "Minimum Stock", + "max_stock_label": "Maximum Stock", + "observations_label": "Observations", + "observations_placeholder": "Additional inventory notes...", + "loading_item_data": "Loading item data...", + "loading_more_items": "Loading more items...", + "invoice_line_info": "Invoice information ({systemLabel})", + "select_line": "Select line", + "import_title": "Import items", + "import_description": "Select a line with available balance to perform the discharge.", + "loading_invoice_items": "Loading invoice items...", + "no_balance": "No balance available", + "no_balance_description": "There are no lines with balance in this invoice to discharge.", + "balance_required": "Available balance line", + "cancel": "Cancel", + "close": "Close", + "saving": "Saving...", + "update": "Update", + "create": "Create" + }, + "prerequisites": { + "title": "Notice", + "message_both": "There are no Customs brokers or Clients registered. You must register them to work in this module.", + "message_agents": "There are no Customs brokers registered. You must register them to work in this module.", + "message_clients": "There are no Clients registered. You must register them to work in this module.", + "register_hint": "You can register them in", + "agents_link": "Customs Brokers", + "clients_link": "Clients and Providers", + "and": "and", + "cancel": "Cancel", + "accept": "Accept" + } + }, + "csv_upload": { + "page_title": "CSV import", + "intro_help": "Left-click: upload CSV file. Right-click: download template.", + "tab_catalogos": "Catalogs", + "tab_transportes": "Transportation", + "tab_importacion": "Import", + "tab_exportacion": "Export", + "section_catalogs": "General Catalogs", + "section_transport": "Transportation", + "section_import": "Import operations", + "section_export": "Export operations", + "params_header": "Global parameters", + "config_prefix": "Settings", + "soon": "Coming soon", + "drop_here": "Drop the file!", + "groups": { + "permisos": "Permissions", + "impo_temp": "Temporary import", + "impo_def": "Definitive import", + "cmex": "Mexican purchases", + "expo_def": "Definitive export / regime change", + "expo_rep": "Export replenishment", + "manifest": "Manifest" + }, + "items": { + "customs_brokers": "Customs Brokers", + "clients_providers": "Clients and Providers", + "exchange_rates": "Exchange Rates", + "material_classes": "Classes", + "part_numbers": "Parts", + "boms": "BOMs", + "items": "Lines (permissions)", + "headers": "Headers (permissions)", + "historical_fractions": "Historical tariff fractions", + "pedimentos": "Pedimentos", + "transporters": "Carriers", + "transports": "Vehicles", + "drivers": "Drivers", + "trailers": "Trailers", + "imp_temp_header": "Header", + "imp_temp_details": "Lines", + "imp_temp_series": "Serial numbers", + "imp_def_header": "Header", + "imp_def_details": "Lines", + "imp_def_series": "Serial numbers", + "comp_mex_header": "Header", + "comp_mex_details": "Lines", + "comp_mex_series": "Serial numbers", + "exp_def_header": "Header", + "exp_def_details": "Lines", + "exp_def_series": "Serial numbers", + "exp_def_nodes": "NODES", + "exp_rep_header": "Header", + "exp_rep_details": "Lines", + "exp_rep_series": "Serial numbers", + "manifest_header": "Header" + }, + "params": { + "load_mode": "Load mode", + "date_format": "Date format", + "weight_unit": "Weight unit", + "autonumber_series": "Autonumber lines/series", + "load_subpartidas": "Load sub-lines", + "recalculate_pedimento_date": "Recalculate pedimento date", + "autonumber_remesas": "Autonumber consignments", + "recalculate_dates": "Recalculate dates", + "invoice_type": "Invoice type", + "is_regime_change": "Regime change" + }, + "options": { + "update": "Update", + "replace": "Replace", + "yes": "Yes", + "no": "No", + "kgs": "Kilograms (kg)", + "lbs": "Pounds (lb)", + "date_dd_mm": "DD/MM/YYYY", + "date_mm_dd": "MM/DD/YYYY", + "date_iso": "YYYY-MM-DD", + "afi": "AFIJO", + "normal": "NORMAL" + }, + "progress": { + "upload": "Uploading CSV file", + "scan": "Validating records on the server", + "commit": "Saving records to the database", + "upload_known": "Uploading file…", + "upload_unknown": "Uploading file (unknown size in browser)…", + "in_progress": "In progress…", + "resume_hint": "Resuming import saved in this tab…", + "rows_file": "File: ~{n} data row(s) — uploading (not yet validated on server)…", + "rows_scan": "Records processed: {current} / {total}", + "rows_commit": "Records saved: {current} / {total}", + "rows_commit_fallback": "Saving to database… ({current} / {total} using last known total)" + }, + "toast": { + "invalid_csv": "Invalid format. Only .csv files are allowed.", + "download_loading": "Downloading template…", + "download_ok": "Template downloaded.", + "download_err": "Could not download the template.", + "upload_err": "Could not upload the file.", + "upload_err_generic": "Unexpected error uploading the file.", + "scan_done": "Scan complete. Review the results.", + "import_done": "Import completed. Review the record list.", + "import_maybe_done": "Import may have completed. Review the record list.", + "stale_job": "This import is no longer available (session expired or job removed). You can start a new upload.", + "poll_err": "Could not fetch status", + "commit_err": "Could not start import", + "scan_alt": "Scan finished. If you do not see the modal, check the record list.", + "finished_none": "No records inserted. Review the errors below.", + "commit_warning_ok": "{inserted} inserted, {updated} updated. {skipped} rejected.", + "commit_warning_none": "No records inserted or updated. {skipped} rejected.", + "success_counts": "Import completed: {msg}", + "warn_skipped": "{n} records rejected or skipped", + "error_processing": "Processing error: {msg}", + "n_inserted": "{n} inserted", + "n_updated": "{n} updated", + "err_fetch_scan_result": "Could not fetch the scan result. Check the results modal.", + "err_unknown": "Unknown error", + "err_processing_fallback": "Processing error. Check the modal or details." + }, + "pending": { + "badge": "Pending", + "title": "Imports pending confirmation", + "description": "Scans ready to save to the database. Expired jobs disappear when you refresh.", + "refresh": "Refresh", + "empty": "No pending imports for this company.", + "checking": "Checking with the server…", + "total_rows": "Total rows", + "valid_rows": "Valid", + "resume": "Resume", + "remove": "Remove", + "profiles": { + "customs_brokers": "Customs Brokers", + "clients_providers": "Clients and Providers", + "exchange_rates": "Exchange Rates", + "pedimentos": "Pedimentos", + "material_classes": "Classes", + "vehicles": "Vehicles", + "drivers": "Drivers", + "trailers": "Trailers", + "transporters": "Carriers", + "part_numbers": "Parts", + "boms": "BOMs", + "exportacion": "Export operations", + "imports": "Import operations" + } + }, + "config_empty": "No module-specific settings.", + "modal": { + "title_pending": "Import validation", + "title_success": "Import successful", + "title_warning": "Import with remarks", + "desc_pending": "Review the preliminary analysis before confirming.", + "desc_done": "The import process has finished.", + "total_rows": "Total rows", + "valid_rows": "Valid", + "invalid_rows": "Invalid", + "errors": "Errors", + "errors_heading": "Scan errors (fix in your CSV)", + "errors_badge": "{shown} of {total} error(s)", + "errors_truncated": "Download the CSV to see all errors.", + "errors_missing_detail": "{count} row(s) had errors but details are not available. Ensure the server is up to date and upload again.", + "scan_ok_title": "File validated successfully", + "scan_ok_body": "All rows look correct and ready to import.", + "scan_problems_title": "Problems found in the file", + "scan_problems_body": "Fix the issues listed below in your CSV and upload again, or confirm to import only valid rows (invalid rows will be skipped).", + "inserted": "Inserted", + "updated": "Updated", + "rejected": "Rejected", + "rejected_hint": "See line-by-line detail in the table below.", + "ref_gaps_title": "Reference gaps (FK / catalogs)", + "ref_gaps_body": "There are {n} critical reference gap(s). Review catalogs and rejected rows before retrying.", + "ref_state_title": "Reference state", + "ref_state_ok": "References ready to operate (no critical gaps reported).", + "ref_state_other": "No numeric gaps; review the server message if applicable.", + "skipped_reasons_heading": "Rejection reasons summary", + "commit_errors_heading": "Error detail", + "rows_badge": "{n} rows", + "importing_records": "Importing records…", + "cancel_operation": "Cancel", + "processing": "Processing…", + "confirm_load": "Confirm import", + "close": "Close", + "th_line": "Line", + "th_column": "Column", + "th_message": "Message", + "th_solution": "Solution", + "th_reference": "Reference", + "th_reason": "Reason", + "download_csv": "Download CSV" + } + }, + "clients_providers": { + "type_client": "Client", + "type_provider": "Provider", + "type_both": "Both" + }, + "common": { + "col_actions": "Actions", + "col_description": "Description", + "col_code": "Code", + "col_key": "Key", + "col_type": "Type", + "col_name": "Name", + "col_status": "Status", + "col_patent": "Patent", + "col_id": "ID", + "col_desc_es": "Spanish Description", + "col_desc_en": "English Description", + "col_desc_en_short": "English Desc.", + "col_value": "Value", + "col_um": "U.M.", + "col_rfc": "RFC", + "col_unit_weight": "Unit Weight", + "col_country": "Country", + "col_level": "Level", + "col_to_code": "To Code", + "col_from_code": "From Code", + "col_fraction": "Fraction", + "col_date": "Date", + "col_conversion_factor": "Conversion Factor", + "col_complement": "Complement", + "col_zip": "ZIP Code", + "col_key_m3": "M3 Code", + "col_key_ame": "AME Code", + "col_classification": "Classification", + "col_class": "Class", + "col_city": "City", + "col_year": "Year", + "col_customs": "Customs", + "col_umt": "U.M.T.", + "col_um_stock": "U.M. Stock", + "col_location": "Location", + "col_transporter": "Carrier", + "col_document_type": "Document Type", + "col_trailer_type": "Trailer Type", + "col_phone": "Phone", + "col_system": "System", + "col_tax_paid": "Tax Paid?", + "col_seal": "Seal", + "col_section": "Section", + "col_photo_path": "Photo Path", + "col_rfc_tax": "RFC / TAX-ID", + "col_rfc_query": "RFC Query", + "col_regime": "Regime", + "col_bonded_warehouse": "Bonded Warehouse", + "col_dest_port": "Destination Port", + "col_arrival_port": "Arrival Port", + "col_mx_exit_port": "MX Exit Port", + "col_program": "Program", + "col_priority": "Priority", + "col_plural": "Plural", + "col_plates": "Plates", + "col_person_in_charge": "Person in Charge", + "col_pedimento": "Pedimento", + "col_country_desc": "Country / Description", + "col_vu_operation": "VU Operation No.", + "col_ext_num": "Ext. No.", + "col_fax": "Fax Number", + "col_trailer_number": "Trailer Number", + "col_pedimento_number": "Pedimento Number", + "col_license_number": "License Number", + "col_note": "Note", + "col_program_no": "Program No.", + "col_part_no": "Part No.", + "col_currency_name": "Currency Name", + "col_driver_name": "Driver Name", + "col_section_name": "Section Name", + "col_full_name": "Full Name", + "col_notice_no": "Notice No.", + "col_local_currency": "Local Currency", + "col_foreign_currency": "Foreign Currency", + "col_currency": "Currency", + "col_month": "Month", + "col_manifest": "Manifest", + "col_localization": "Location", + "col_line": "Line", + "col_amount": "Amount", + "col_fraction_us": "US Fraction", + "col_signature": "Signature", + "col_modified_date": "Modified Date", + "col_start_date": "Start Date", + "col_end_date": "End Date", + "col_entry_date": "Entry Date", + "col_payment_date": "Payment Date", + "col_created_date": "Creation Date", + "col_email": "Email", + "col_edocument": "E-Document", + "col_address": "Address", + "col_cost": "Cost", + "col_container": "Container", + "col_consecutive": "Consecutive", + "col_concept": "Concept", + "col_neighborhood": "Neighborhood", + "col_location_code": "Location Code", + "col_regime_code": "Regime Code", + "col_port_code": "Port Code", + "col_pedimento_code": "Pedimento Code", + "col_customs_code": "Customs Code", + "col_ace_code": "ACE Code", + "col_aamex_code": "AAMEX Code", + "col_a76_code": "A76 / SCAII Code", + "col_client": "Client", + "col_carrier_key": "Carrier Key", + "col_key_mx": "MX Code", + "col_broker_key": "Broker Key", + "col_street": "Street", + "col_authorized": "Authorized", + "col_file": "File", + "col_last_name": "Last Name", + "col_e_receipt": "Electronic Receipt", + "btn_refresh": "Refresh", + "btn_new_record": "New Record", + "sub_countries": "Manage the countries available in the system", + "new_country": "New Country", + "sub_sectors": "Manage the system's economic sectors", + "new_sector": "New Sector", + "sub_states": "Manage the states and regions of the customs system", + "sub_currency_types": "Manage the currency types available in the system", + "new_currency_type": "New Currency Type", + "sub_containers": "Container Types Catalog", + "sub_customs_sections": "Manage the system's customs sections", + "new_section": "New Section", + "sub_customs_warehouses": "Manage the bonded customs warehouses in the system", + "new_warehouse": "New Warehouse", + "sub_invoice_types": "Manage the invoice types in the system", + "new_type": "New Type", + "sub_material_types": "Manage the material types available in the system", + "sub_payment_methods": "Manage the payment methods available in the system", + "sub_pedimento_codes": "Manage the pedimento codes of the customs system", + "sub_pedimento_regimens": "Manage the pedimento regimes in the system", + "new_regime": "New Regime", + "sub_transport_modes": "Manage the transportation methods available in the system", + "new_transport_mode": "New Mode", + "sub_transport_types": "Manage the transportation types of the customs system", + "sub_valuation_methods": "Manage the valuation methods in the system", + "new_method": "New Method", + "sub_code_pedimento_regimens": "Relationship between pedimento codes and customs regimes", + "sub_document_types_digitization": "View the read-only fixed catalog used by digitization and pedimentos.", + "sub_concepts": "Concepts Catalog", + "sub_customs_broker_concepts": "Customs Broker Concepts Catalog", + "sub_classification_concepts": "Concept Classifications Catalog", + "sub_identifiers": "System Identifiers Catalog", + "sub_legends": "System Legends Catalog", + "sub_seals": "System Seals Catalog", + "sub_ports": "System Ports Catalog", + "sub_prevalidators": "Prevalidators Catalog", + "new_prevalidator": "New Prevalidator", + "sub_electronic_notices": "System Electronic Notices Catalog", + "sub_inpc": "System I.N.P.C. Catalog", + "sub_error_catalogs": "System Error Catalogs", + "sub_packages": "System Packages Catalog", + "sub_um_general": "General units of measure catalog", + "sub_um_customs": "System Customs Units Catalog", + "sub_um_american": "System American Units Catalog", + "sub_um_ace": "System ACE Units Catalog", + "sub_um_oma": "System OMA Units Catalog", + "new_unit": "New Unit", + "sub_unit_conversions": "Manage the catalog of unit conversions between systems", + "new_conversion": "New Conversion", + "sub_exchange_rate": "Manage the catalog of official and custom exchange rates", + "sub_signatures": "System Signatures Catalog", + "sub_multi_currency": "Manage the multi-currency types catalog", + "sub_equivalencies": "Manage the catalog of units of measure equivalences", + "new_equivalence": "New Equivalence", + "sub_company_information": "Manage company information", + "new_company": "New Company", + "sub_transporters": "Manage the carrier lines catalog", + "new_transporter": "New Carrier", + "sub_drivers": "Manage the drivers catalog", + "new_driver": "New Driver", + "sub_trailers": "Manage the company's trailers catalog", + "new_trailer": "New Trailer", + "sub_pedimentos": "Manage the system's pedimentos", + "new_pedimento": "New Pedimento", + "sub_brokers": "Customs Brokers and Sections Management", + "sub_parts": "Manage and view inventory and fixed asset parts", + "new_part": "New Part", + "title_classes_inventory": "Inventory Classes", + "title_classes_fa": "Fixed Asset Classes", + "sub_classes_inventory": "Manage and view inventory system classes", + "sub_classes_fa": "Manage and view fixed asset classes", + "sub_fda": "FDA goods code management", + "new_fda_code": "New Code", + "btn_new_short": "New", + "title_users": "Users and Roles Management", + "sub_users": "Manage your organization's users, roles and permissions", + "title_roles": "Roles and Permissions", + "sub_roles": "Manage the company's roles and their permissions", + "title_settings": "General Settings", + "sub_settings": "Configure the system parameters.", + "title_account": "Account Settings", + "sub_account": "Manage your personal information and preferences", + "sub_expiration_report": "Tax Control Reports", + "maint_title": "Under Construction", + "maint_desc": "MODULE NOT YET AVAILABLE!", + "maint_back": "Back to Home", + "sub_manifest": "Export manifests management", + "title_invoice_report": "Invoice Report", + "title_help_library": "Knowledge Library", + "sub_help": "System Manuals, Guides and Documentation.", + "new_chapter": "New Chapter", + "help_search_ph": "Search the library...", + "help_empty": "The library is empty.", + "help_format": "Format", + "help_unknown": "Unknown", + "help_size": "Size", + "help_watch": "Watch", + "help_read": "Read", + "help_open": "Open", + "help_delete_title": "Delete chapter?", + "help_delete_desc": "\"{title}\" will be permanently deleted.", + "help_toast_load_error": "Error loading articles", + "help_toast_deleted": "Chapter deleted", + "help_toast_error": "Error", + "help_back_library": "Back to Library", + "help_chapter_load_error": "Error loading chapter", + "help_pdf_document": "PDF Document", + "help_open_tab": "Open in tab", + "help_download": "Download", + "help_video_unsupported": "Your browser does not support videos.", + "help_file_info": "File information", + "help_file_download": "File to download", + "help_no_preview": "This file has no direct preview.", + "help_download_now": "Download now", + "help_in_chapter": "In this chapter", + "btn_cancel": "Cancel", + "btn_delete": "Delete", + "btn_edit": "Edit", + "btn_save": "Save", + "help_toast_article_load_error": "Error loading article", + "help_toast_chapter_created": "Chapter created", + "help_toast_changes_saved": "Changes saved", + "help_toast_save_error": "Error saving", + "help_toast_uploading_image": "Uploading image...", + "help_toast_image_inserted": "Image inserted", + "help_toast_upload_error": "Error uploading", + "help_toast_uploading_file": "Uploading {name}...", + "help_untitled": "Untitled", + "help_toast_file_uploaded": "File uploaded successfully", + "help_toast_file_upload_error": "Error uploading file", + "help_back": "Back", + "help_hide_preview": "Hide Preview", + "help_show_preview": "Show Preview", + "help_config": "Settings", + "help_field_title": "Title", + "help_ph_title": "E.g.: Introduction", + "help_field_slug": "Identifier (Slug)", + "help_ph_slug": "eg-article-title", + "help_slug_hint": "Auto-generated from the title if left empty.", + "help_field_type": "Content Type", + "help_type_article": "Article (Markdown)", + "help_type_document": "Other Document", + "help_advanced": "Advanced Options", + "help_field_category": "Category", + "help_ph_category": "E.g.: General", + "help_field_order": "Order", + "help_field_context": "Context Path", + "help_ph_context": "E.g.: /dashboard/...", + "help_context_hint": "URL where this article will appear.", + "help_field_tags": "Tags", + "help_ph_tags": "E.g.: invoices, scrap", + "help_bold": "Bold", + "help_italic": "Italic", + "help_h1": "Heading 1", + "help_h2": "Heading 2", + "help_list": "List", + "help_link": "Link", + "help_upload_image": "Upload Image", + "help_ph_content": "# Start writing here...", + "help_drag_images": "Drag images here", + "help_config_pdf": "PDF Settings", + "help_config_video": "Video Settings", + "help_config_document": "Document Settings", + "help_upload_hint": "Upload the file you want to associate with this title.", + "help_file_loaded": "File Loaded", + "help_view_file": "View File", + "help_change_file": "Change File", + "help_select_file": "Select a file", + "help_drag_drop": "Or drag and drop here" + } +} diff --git a/frontend/messages/es.json b/frontend/messages/es.json new file mode 100644 index 0000000..64cc299 --- /dev/null +++ b/frontend/messages/es.json @@ -0,0 +1,2094 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "hello_world": "Hello, {name} from es!", + "exchange_rate_error_title": "Error de Tipo de Cambio", + "dashboard": { + "greeting_morning": "Buenos días", + "greeting_afternoon": "Buenas tardes", + "greeting_evening": "Buenas noches", + "team_suffix": ", equipo.", + "operational_summary": "Resumen operativo — Anexos 22/24/30", + "management_system": "Sistema de gestión de comercio exterior", + "loading": "Cargando...", + "update": "Actualizar", + "operations_distribution": "Distribución de Operaciones", + "top_clients": "Top 5 Clientes", + "top_providers": "Top 5 Proveedores", + "by_invoice_number": "Por número de facturas", + "quick_access": "Accesos Rápidos", + "most_used_modules": "Módulos más utilizados", + "invoices": "Facturas", + "manage_invoices": "Gestionar facturas", + "clients_and_providers": "Clientes y Proveedores", + "manage_contacts": "Administrar contactos", + "goods": "Mercancías", + "product_catalog": "Catálogo de productos", + "reference_data": "Datos de Referencia", + "sat_catalogs": "Catálogos del SAT", + "total_documents": "Total Documentos", + "total_contacts": "Total Contactos", + "active_items_stat": "Items Activos", + "no_active_company": "No hay compañía activa seleccionada", + "load_error": "Error al cargar el dashboard", + "pedimentos": "Pedimentos", + "clients": "Clientes", + "providers": "Proveedores", + "pending_approvals": "Pendientes", + "operations_trend": "Tendencia de Operaciones", + "monthly_evolution": "Evolución mensual de operaciones", + "recent_activity": "Actividad Reciente", + "latest_operations": "Últimas operaciones registradas en el sistema", + "no_recent_activity": "No hay actividad reciente", + "records_suffix": "registros", + "just_now": "Ahora mismo", + "ago_suffix": "Hace", + "min_short": "min", + "h_short": "h", + "d_short": "d", + "no_data_available": "Sin datos disponibles", + "data_will_appear_here": "Los datos aparecerán aquí una vez registrados", + "operations_in": "operaciones en", + "more_than_one_month_needed": "La gráfica aparecerá con más de un mes de datos", + "average_per_month": "Promedio / mes", + "maximum": "Máximo", + "total": "Total", + "operations": "operaciones", + "previous_month": "mes ant.", + "operations_breakdown": "Desglose por tipo de operación", + "ops_short": "ops", + "operations_will_appear_here": "Las operaciones aparecerán aquí una vez registradas", + "no_data_available_short": "No hay datos disponibles" + }, + "exchange_rate": { + "new_title": "Nuevo Tipo de Cambio", + "edit_title": "Editar Tipo de Cambio", + "required_title": "Tipo de Cambio Requerido", + "required_description": "Para continuar con el guardado, es necesario registrar el tipo de cambio oficial para esta fecha.", + "applicable_date": "Fecha Aplicable", + "exchange_rate_label": "Tipo de Cambio (MXN/USD)", + "required_badge": "Requerida", + "consulting": "Consultando...", + "consult_dof": "Consultar DOF", + "example_suffix": "Ej.", + "cancel": "Cancelar", + "ok": "Ok", + "confirm_title": "¿Estás seguro?", + "confirm_description_create": "Se creará el tipo de cambio con valor {value} para el día {date}.", + "confirm_description_update": "Se actualizará el tipo de cambio con valor {value} para el día {date}.", + "confirm_action": "Confirmar", + "toast_dof_success": "Tipo de cambio obtenido del DOF: {value}", + "toast_dof_error": "No se pudo obtener el dato del DOF", + "toast_dof_service_error": "Error al consultar el servicio del DOF", + "error_no_company": "No hay una compañía seleccionada", + "error_date_required": "La fecha es requerida", + "error_value_required": "El tipo de cambio es requerido", + "error_value_positive": "El tipo de cambio debe ser un valor mayor a 0", + "toast_create_success": "Tipo de cambio creado correctamente", + "toast_update_success": "Tipo de cambio actualizado correctamente" + }, + "multi_currency": { + "new_title": "Nuevo Tipo de Cambio Múltiple", + "edit_title": "Editar Tipo de Cambio Múltiple", + "error_currency_required": "El código de moneda es requerido", + "error_date_required": "La fecha de publicación es requerida", + "currency_label": "Moneda", + "currency_help": "Código de moneda (FK).", + "country_label": "País", + "country_help": "Clave M3 del país (FK).", + "date_label": "Fecha", + "date_help": "Se guarda como entero (YYYYMMDD).", + "factor_label": "Factor", + "saving": "Guardando...", + "update": "Actualizar", + "create": "Crear" + }, + "sidebar": { + "dashboard": "Dashboard", + "help_center": "Manuales del Sistema", + "management_label": "Gestión", + "bulk_upload": { + "title": "Cargas masivas", + "entry": "Importación CSV" + }, + "reference_data": { + "title": "Catálogos Fijos", + "codes_pedimento_regimen": "Códigos de Pedimento y Régimen", + "containers": "Contenedores", + "countries": "Países", + "currency_types": "Tipos de moneda", + "customs_sections": "Secciones de aduanas", + "customs_warehouses": "Recintos", + "incoterms": "Incoterms", + "document_types_digitization": "Tipos de documento para digitalización", + "invoice_types": "Tipos de factura", + "material_types": "Tipos de material", + "payment_methods": "Métodos de pago", + "pedimento_codes": "Códigos de pedimento", + "pedimento_regimes": "Regímenes de pedimentos", + "sectors": "Sectores", + "states": "Estados", + "transportation_modes": "Métodos de transporte", + "transportation_types": "Tipos de transporte", + "valuation_methods": "Métodos de valoración", + "configuracion": "Configuración", + "general": "General", + "licencia": "Licencia", + "usuarios": "Usuarios", + "ayuda": "Ayuda" + }, + "general_catalogs": { + "title": "Catalogos Generales", + "company_information": "Información de la empresa", + "packages": "Bultos", + "concepts": "Conceptos", + "classification": "Clasificación", + "identifiers": "Identificadores", + "incoterms": "Incoterms", + "inpc": "I.N.P.C", + "fixed_legends": "Leyendas fijas", + "seals": "Precintos", + "valuation_methods": "Metódos de valoración", + "countries": "Países", + "ports": "Puertos", + "unit_measures": "Unidades de medida", + "um_customs_mex": "UM Aduanas MX", + "um_customs_ame": "UM Aduanas USA", + "um_ace": "UM ACE", + "um_oma": "UM OMA", + "conversions": "Conversiones", + "equivalences": "Equivalencias", + "exchange_rates": "Tipos de cambio", + "currency_types": "Tipos de moneda", + "multi_currency": "Multi Moneda", + "invoice_types": "Tipos de factura", + "electronic_signatures": "Firmas electrónicas", + "billing_errors": "Errores de facturación", + "customs_warehouses": "Recintos", + "locations": "Localizaciones", + "doda": "DODA", + "packing_list": "Packing List", + "prevalidators": "Prevalidadores", + "electronic_notices": "Avisos electrónicos", + "back_flush": "Back Flush", + "crossing_notice": "Aviso de cruce", + "customs_broker_concepts": "Conceptos de Agente Aduanal" + }, + "fractions": { + "title": "Fracciones", + "sitar": "Fracciones Sitar", + "sitar_seventh_amendment": "Fracciones Sitar - 7ma enmienda", + "sitar_us": "Fracciones Sitar US", + "american": "Fracciones US", + "canadian": "Fracciones Canadiense", + "historical": "Fracciones Historicas", + "sectors": "Sectores" + }, + "goods": { + "title": "Mercancías", + "classes": "Clases", + "parts": "Partes", + "fda_codes": "Códigos F.D.A." + }, + "pedimentos": { + "title": "Pedimentos", + "pedimento_management": "Gestión de Pedimentos", + "pedimento_codes": "Claves de Pedimento", + "customs_regimes": "Regímenes Aduaneros", + "payment_methods": "Formas de Pago", + "customs_sections": "Secciones Aduaneras", + "anexo_22_app_31": "Anexo 22 App 3" + }, + "import_invoices": { + "title": "Facturas de importación", + "temporary": "Temporal", + "definitive": "Definitiva", + "mexican_purchases": "Compras mexicanas", + "regime_change": "Cambio de régimen", + "repair": "Reparación" + }, + "export_invoices": { + "title": "Facturas de exportación", + "exportation": "Exportación", + "repair": "Reparación" + }, + "export": { + "title": "Exportación", + "catalog": "Catálogo de exportación", + "repair": "Reparación", + "manifest": "Manifiesto", + "proforma": "Proforma", + "reports": "Reportes", + "used_materials": "Módulo de materiales utilizados", + "destruction": "Destrucción", + "special_processes": "Procesos Especiales" + }, + "clients_and_providers": "Clientes y Proveedores", + "customs_brokers": "Agentes Aduanales", + "audit_logs": "Bitácora", + "audit_logs_title": "Bitácora de Movimientos", + "audit_logs_description": "Auditoría de operaciones y seguimiento de tareas en segundo plano (Celery).", + "audit_logs_tab_bitacora": "Bitácora", + "audit_logs_tab_tasks": "Tareas en segundo plano", + "audit_logs_tab_files": "Gestor de archivos", + "audit_logs_files_title": "Gestor de archivos", + "audit_logs_files_root": "Raíz de archivos", + "audit_logs_files_refresh": "Actualizar", + "audit_logs_files_list_title": "Contenido", + "audit_logs_files_error_prefix": "Error:", + "audit_logs_files_col_name": "Nombre", + "audit_logs_files_col_size": "Tamaño", + "audit_logs_files_col_modified": "Modificado", + "audit_logs_files_col_actions": "Acciones", + "audit_logs_files_loading": "Cargando archivos...", + "audit_logs_files_empty": "No hay archivos o carpetas en esta ubicación.", + "audit_logs_files_download": "Descargar", + "despacho": { + "title": "Despacho", + "digitalizacion": "Digitalización", + "doda": "DODA" + }, + "doda_alta": { + "title": "DODA", + "subtitle": "Declaración Operación Despacho Aduanero", + "new": "Nuevo", + "refresh": "Actualizar", + "table_title": "DODAs", + "col_integration_number": "No. Integración", + "col_patent": "Patente", + "col_status": "Estatus", + "col_dispatch_customs": "Aduana Despacho", + "col_operation_type": "Tipo Operación", + "col_actions": "Acciones", + "action_alta_doda": "Alta DODA", + "action_alta_pita": "Alta PITA", + "action_edit": "Editar", + "action_delete": "Borrar", + "action_new": "Nuevo DODA", + "progress_title": "Procesando alta DODA...", + "progress_success": "Alta DODA completada exitosamente.", + "progress_error": "Error en el alta DODA.", + "eligibility_error": "El DODA no cumple los requisitos para el alta.", + "eligibility_checking": "Verificando elegibilidad...", + "empty": "Sin DODAs", + "loading": "Cargando...", + "search_placeholder": "Buscar:", + "confirm_delete": "¿Está seguro de eliminar este DODA?", + "delete_success": "DODA eliminado correctamente", + "delete_error": "Error al eliminar DODA", + "delete_missing_company": "Selecciona una compañía", + "delete_select_one": "Selecciona un solo DODA en el listado", + "delete_not_found": "No se pudo localizar el DODA. Pulsa otra fila e inténtalo de nuevo", + "filter_integration_number": "No. Integración", + "filter_patent": "Patente", + "filter_status": "Estatus", + "filter_operation_type": "Tipo Operación", + "action_generar": "Generar", + "action_export_excel": "Reporte por fechas", + "action_export_pedimentos": "Reporte DODA", + "export_pedimentos_success": "Reporte DODA generado.", + "export_pedimentos_error": "No se pudo generar el reporte DODA.", + "export_excel_title": "Exportar listado DODA", + "export_excel_subtitle": "Filtra por Fecha DODA (en base de datos como AAAAMMDD).", + "export_excel_badge": "CATÁLOGO DODA", + "export_report_heading": "Reporte general por rango de fechas", + "export_fecha_inicio": "Fecha inicio", + "export_fecha_final": "Fecha final", + "export_julian_label": "Imprimir Fecha Juliana en archivo Excel.", + "export_report_generar": "Generar", + "export_date_from": "Desde", + "export_date_to": "Hasta", + "export_format": "Formato de archivo", + "export_date_mode": "Fechas y hora en el archivo", + "export_date_mode_formatted": "Formateado (DD/MM/YYYY y hora)", + "export_date_mode_raw": "Numérico (YYYYMMDD / crudo)", + "export_download": "Descargar", + "export_cancel": "Cerrar", + "export_excel_success": "Archivo generado.", + "export_excel_error": "No se pudo generar el archivo.", + "export_no_data": "No hay DODA en el rango de fechas elegido. Amplía el rango o prueba otras fechas.", + "export_excel_invalid_dates": "Indique fecha desde y hasta." + }, + "digitalizacion": { + "title": "Digitalización", + "subtitle": "Catálogo de Documentos Digitalizados", + "new": "Nuevo", + "refresh": "Actualizar", + "table_title": "Documentos digitalizados", + "col_consecutivo": "Consecutivo", + "col_tipo_documento": "Tipo Documento", + "col_e_document": "E-Document", + "col_fecha": "Fecha", + "col_num_operacion_vu": "Núm. Operación VU", + "col_actions": "Acciones", + "form_e_document": "E-Document", + "form_num_operacion": "Núm. Operación", + "form_tipo_documento": "Tipo Documento", + "form_archivo_digitalizado_en": "Archivo Digitalizado en", + "form_fecha": "Fecha", + "form_agente_aduanal": "Agente Aduanal", + "form_pedimento": "Pedimento", + "form_nombre_archivo": "Nombre del archivo", + "digitalizar_title": "Digitalizar Documento", + "digitalizar_subtitle": "Enviar documento a Ventanilla Única", + "digitalizar_file_label": "Archivo", + "digitalizar_rfc_consulta": "RFC Consulta", + "digitalizar_clave_documento": "Clave Documento", + "progress_title": "Digitalizando documento...", + "progress_step": "Paso", + "progress_success": "Digitalización completada exitosamente.", + "progress_download_acuse": "Descargar Acuse", + "action_digitalizar": "Digitalizar", + "action_download_zip": "Descargar ZIP", + "action_acuse": "Acuse", + "action_envio_xml": "Envío XML", + "action_respuesta_xml": "Respuesta XML", + "action_consulta_envio_xml": "Consulta Envío XML", + "action_consulta_respuesta_xml": "Consulta Respuesta XML", + "action_edit": "Editar", + "action_delete": "Borrar", + "empty": "Sin documentos digitalizados", + "loading": "Cargando...", + "search_placeholder": "Buscando:", + "confirm_delete": "¿Está seguro de eliminar este documento?" + }, + "client_provider_type": { + "client_indicator": "C", + "provider_indicator": "P", + "both_indicator": "A" + }, + "nav_user": { + "profile": "Perfil", + "settings": "Configuración" + }, + "transports": { + "title": "Transportes", + "transporters": "Transportistas", + "drivers": "Conductores", + "trailers": "Trailers", + "vehicles": "Vehículos", + "vehicle_transport_types": { + "ar": "Camión Blindado", + "au": "Automóviles", + "bt": "Camión de Caja", + "bu": "Autobús", + "bv": "Camión de Bebidas", + "by": "Bicicleta", + "co": "Vehículo de Construcción", + "ev": "Vehículo de Emergencia", + "fe": "Ferry", + "fm": "Tractor Agrícola", + "gb": "Camión de Basura", + "mc": "Motocicleta", + "oc": "Otro", + "pm": "Camioneta con cabina", + "pn": "Camión Panel", + "pu": "Camioneta (Pick-up)", + "pv": "Pasajero", + "rv": "Vehículo Recreativo (RV)", + "tr": "Tractocamión", + "tv": "Van" + } + }, + "reports": { + "title": "Reportes", + "invoices": "Facturas Impo/Expo", + "downloaded_parts": "Partes descargadas", + "expiration": "Reporte de Vencimiento" + }, + "settings": { + "general": "General" + }, + "doda_form": { + "shortcuts_scope": "Formulario DODA", + "title_new": "Nuevo DODA", + "title_edit": "Editar DODA", + "description_catalog": "Catálogos · DODA", + "tab_general": "General", + "tab_seals_sat": "Sellos y SAT", + "shortcuts_hint": "Alt+1/2 · Ctrl+S guardar · Esc cancelar", + "btn_cancel": "Cancelar", + "btn_save": "Guardar", + "btn_saving": "Guardando...", + "btn_save_changes": "Guardar cambios", + "btn_create_doda": "Crear DODA", + "btn_accept": "Aceptar", + "card_broker_customs": "Agente aduanal y aduana", + "card_transport": "Transporte", + "card_control": "Control y despacho", + "card_sat_chain": "Cadena original y firmas (SAT)", + "label_responsible": "Responsable", + "label_patent": "Patente", + "label_dispatch": "Aduana despacho", + "label_section_es": "Aduana sección E/S", + "label_operation_type": "Tipo operación", + "label_transporter": "Transportista", + "label_transport_id": "ID transporte", + "label_caat": "CAAT", + "label_doda_date": "Fecha DODA", + "label_status": "Estatus", + "label_dispatch_type": "Tipo despacho", + "label_unique_badge": "Gafete único", + "label_integration_num": "Núm. integración", + "label_transaction_num": "Núm. transacción", + "label_fast_id": "Fast ID", + "label_last_user": "Último usuario", + "label_original_chain": "Cadena original", + "label_serial_cert": "Núm. serie (certificado)", + "label_uuid_cp": "UUID carta porte", + "label_electronic_sig": "Firma electrónica", + "label_sat_cert": "Certificado SAT", + "label_sat_chain": "Cadena original SAT", + "ph_aga": "Clave AGA", + "ph_0000": "0000", + "ph_000": "000", + "ph_select": "Seleccionar", + "ph_plate": "Placa / ID vehículo", + "ph_dash": "—", + "ph_yyyymmdd": "AAAAMMDD", + "ph_badge_pita": "N/A — PITA", + "ph_badge_num": "Núm. gafete", + "ph_example_container": "Ej. 53056", + "op_import": "I — Importación", + "op_export": "E — Exportación", + "type_pita": "PITA", + "type_doda": "DODA", + "vu_checking": "Verificando VU DODA del agente…", + "vu_incomplete": "VU DODA incompleta: se requiere .cer, .key y clave FIEL DODA del agente.", + "vu_complete": "VU DODA completa para envío a API.", + "badge_required_hint": "Requerido para alta DODA en API.", + "pedimentos": "Pedimentos", + "lines": "líneas", + "containers": "Contenedores", + "american_pedimentos": "Pedimentos americanos", + "seals_block_title": "Precintos (candados) — total en el DODA: {n} / 8", + "seals_help": "Selecciona un contenedor en la tabla. Máximo 8 precintos en todo el DODA (regla SCAII).", + "seals_select_container": "Selecciona un contenedor en la tabla de contenedores para ver o editar sus precintos.", + "container_no_id_warning": "Contenedor sin id en el servidor. Completa el valor, pulsa Guardar (arriba); al guardar se envían contenedores nuevos y se recargan con id para precintos.", + "container_line_info": "Contenedor:", + "seal_on_line": "precinto(s) en esta línea", + "line_word": "Línea", + "btn_add_seal": "Agregar precinto", + "btn_seal_delete": "Eliminar", + "seals_empty_line": "Sin precintos en este contenedor.", + "col_line": "Línea", + "col_auth_patent": "Patente auth.", + "col_document": "Documento", + "col_remesa": "Remesa", + "col_cove": "COVE", + "col_umc": "UMC", + "col_cash_usd": "Efectivo USD", + "col_diff_usd": "Diferencia USD", + "col_dta_niu": "DTA NIU", + "col_art7": "Art. 7", + "col_container": "Contenedor", + "col_seals": "Precintos", + "col_seal_value": "Precinto", + "col_american_type": "Tipo", + "col_american_ped": "Pedimento americano", + "col_pedimento_only": "Pedimento americano", + "yes": "Sí", + "no": "No", + "child_empty": "Sin filas. «Nuevo» para añadir.", + "child_new": "Nuevo", + "child_edit": "Editar", + "child_delete": "Borrar", + "modal_container_new": "Nuevo contenedor", + "modal_container_edit": "Editar contenedor", + "modal_container_desc": "Captura el valor del contenedor para la declaración DODA.", + "label_container_value": "Valor contenedor", + "modal_seals_in_container": "Precintos del contenedor", + "seal_modal_title": "Contenedores > Precinto", + "seal_modal_desc": "Captura el valor del precinto para el contenedor seleccionado.", + "label_seal": "Precinto", + "ph_seal": "Valor del precinto", + "american_modal_title": "Pedimento Americano", + "american_modal_desc": "Captura el tipo y valor del pedimento americano.", + "label_american_type_short": "Tipo Ped. Americano", + "label_american_value": "Pedimento Americano", + "ph_american_value": "Valor pedimento americano", + "line_label": "Línea:", + "select_type": "Selecciona tipo", + "american_cat_6": "PEDIMENTO AMERICANO", + "american_cat_7": "AUTODECLARACION", + "american_cat_8": "NO PRESENTA", + "err_american_tipo_required": "El tipo de pedimento americano es obligatorio.", + "err_american_tipo_import": "El tipo de pedimento americano no es correcto para importación (debe ser 1, 2, 3, 4 o 5).", + "err_american_tipo_export": "El tipo de pedimento americano no es correcto para exportación (debe ser 6, 7 u 8).", + "err_american_op_undefined": "Define el tipo de operación (I/E) antes de validar el pedimento americano.", + "err_company": "Selecciona una compañía", + "err_responsible": "El Responsable es requerido", + "err_patent": "El Agente Aduanal (Patente) es requerido", + "err_transport": "La Identificación de Transporte es requerida. Selecciona un vehículo.", + "err_badge": "El Número de Gafete Único es requerido para Alta DODA.", + "err_vu_wait": "Espera a que termine la verificación VU DODA del agente e intenta de nuevo.", + "err_vu_config": "El agente aduanal no tiene configuración VU DODA completa (.cer, .key y clave FIEL DODA).", + "err_min_containers": "Agrega al menos un contenedor con valor para el envío a API.", + "err_american_new_lines": "Indique el valor del pedimento americano en cada línea nueva.", + "err_save": "Error al guardar", + "toast_saved": "Cambios guardados correctamente.", + "toast_created": "DODA creado correctamente.", + "load_error": "No se pudo cargar la información del DODA", + "warn_vu_incomplete": "El agente aduanal de este DODA no tiene VU DODA completa (.cer, .key y clave FIEL DODA).", + "warn_vu_fetch": "No se pudo validar la configuración VU del agente aduanal.", + "warn_broker_select": "El agente seleccionado no tiene VU DODA completa (.cer, .key y clave FIEL DODA). Configúralo en Agentes Aduanales antes de generar.", + "seal_save_first": "Guarda el DODA antes de gestionar precintos.", + "seal_pick_container": "Selecciona un contenedor en la tabla.", + "seal_not_persisted": "Este contenedor aún no está guardado en el servidor. Guarda el DODA (Guardar) y vuelve a abrir o recarga.", + "seal_empty": "El precinto no puede estar vacío.", + "seal_max": "El DODA ya tiene el máximo de 8 precintos.", + "seal_add_err": "Error al agregar el precinto", + "seal_delete_err": "Error al eliminar el precinto", + "pedimento_remove_blocked": "Los pedimentos guardados en servidor no se pueden quitar aquí.", + "container_delete_err": "Error al eliminar el contenedor", + "american_delete_err": "Error al eliminar el pedimento americano", + "container_update_err": "Error al actualizar el contenedor", + "american_cannot_edit_persisted": "Para editar pedimentos americanos guardados, elimínalo y créalo nuevamente.", + "err_american_value": "Indique el valor del pedimento americano.", + "err_american_type_or_value": "Capture tipo o valor del pedimento americano.", + "err_containers_max": "El DODA solo puede tener máximo 4 contenedores.", + "err_container_empty": "El valor del contenedor no puede estar vacío.", + "err_container_not_found": "No se encontró el contenedor a editar.", + "pedimento_selector_title": "Contenedores > Precinto", + "list_page_subtitle": "Gestiona tus Documentos de Operación Aduanera (DODA)", + "list_btn_new": "Nuevo DODA", + "list_card_title": "Listado de DODA", + "list_ph_folio": "Folio", + "list_ph_patent": "Patente", + "list_filter_status_ph": "Estatus", + "list_filter_status_all": "Todos", + "list_filter_op_import": "Importación", + "list_filter_op_export": "Exportación", + "list_filter_op": "Operación", + "list_filter_op_all": "Todas", + "list_btn_clear": "Limpiar", + "list_showing": "Mostrando {a} de {b} registros", + "list_active_filters": "Filtros activos: {n}", + "list_btn_edit": "Editar", + "list_btn_print": "Imprimir", + "list_toast_reload_error": "Error al recargar datos", + "list_elig_error_prefix": "Error al verificar elegibilidad: ", + "list_elig_not_meet": "El DODA no cumple con los requisitos de alta.", + "list_alta_error_prefix": "Error al enviar alta DODA: ", + "list_print_error": "Error al generar el PDF del DODA", + "list_alta_complete": "Alta DODA completada correctamente", + "list_shortcuts_scope": "Lista DODA", + "list_col_folio": "Folio", + "list_col_doda_date": "Fecha DODA", + "list_col_desp": "Desp.", + "list_col_patent": "Patente", + "list_col_pedimentos": "Pedimento(s)", + "list_col_remesas": "Remesa(s)", + "list_col_integracion": "Integración", + "list_col_trans": "Núm. Transacción", + "list_col_id_transport": "Id. Transporte", + "list_col_caat": "CAAT", + "list_col_user": "Usuario", + "list_col_status": "Estatus", + "list_loading_more": "Cargando más...", + "list_scroll_for_more": "Desplázate para cargar más", + "list_confirm_delete": "¿Está seguro de eliminar este registro DODA?", + "list_toast_delete_ok": "DODA eliminado correctamente", + "list_toast_delete_err": "Error al eliminar DODA", + "list_filter_i": "I - Importación", + "list_filter_e": "E - Exportación", + "list_no_results": "No hay resultados." + } + }, + "invoice_list": { + "skip_to_actions": "Ir a acciones de factura", + "header": { + "title": "Facturas", + "description": "Gestiona las facturas del sistema" + }, + "titles": { + "base": "CATALOGO DE FACTURAS", + "import": "DE IMPORTACION", + "export": "DE EXPORTACION", + "import_temporal": "DE IMPORTACION TEMPORAL", + "import_definitive": "DE IMPORTACION DEFINITIVA", + "import_mexican": "DE COMPRAS MEXICANAS", + "import_regime_change": "DE CAMBIO DE REGIMEN Y REGULARIZACION", + "import_repair": "DE IMPO. DE REPARACION", + "export_definitive": "DE SALIDA DEFINITIVA", + "export_repair": "DE REPARACION" + }, + "filters": { + "operation_label": "Tipo de Operacion", + "operation_all_option": "Operacion: Todas", + "invoice_type_label": "Tipo de Factura", + "invoice_type_all_option": "Factura: Todas", + "invoice_number_placeholder": "No. Factura", + "year_start_placeholder": "Ano inicio", + "year_end_placeholder": "Ano fin", + "active_filters": "Filtros activos" + }, + "actions": { + "parameters": "Parametros", + "new_invoice": "Nueva Factura", + "refresh": "Actualizar", + "reports": "Reportes", + "more_actions": "Mas Acciones", + "downloads": "Descargas", + "other_actions": "Otras Acciones", + "cancel": "Cancelar", + "continue": "Continuar", + "generate_cove": "Generar COVE", + "close": "Cerrar" + }, + "card": { + "invoice_list_title": "Listado de Facturas" + }, + "summary": { + "showing": "Mostrando", + "of": "de", + "records": "registros" + }, + "operation_types": { + "all": "Todas", + "import": "Importacion", + "export": "Exportacion" + }, + "cove_dialog": { + "title": "Generar COVE", + "description_prefix": "Selecciona el correo destinatario para la factura", + "recipient_label": "Correo destinatario", + "destination": "Destino COVE", + "select_email": "Selecciona un correo", + "fallback_email": "Se enviara al correo del usuario que genero la factura", + "search_email": "Buscar correo...", + "loading_emails": "Cargando correos disponibles...", + "no_emails": "No hay correos disponibles para COVE.", + "selected_badge": "Seleccionado" + }, + "progress": { + "title_pdf": "Generando PDF de Factura", + "title_consolidated": "Generando Consolidado", + "title_descargo": "Generando Reporte PEPS", + "title_packing_list": "Generando Packing List", + "title_winsaai": "Generando Reporte WINSAAI", + "title_process_invoice": "Procesando factura", + "title_revert_invoice": "Des-actualizando factura", + "title_validate_cove": "Validando datos para COVE", + "complete_processed": "Factura procesada correctamente", + "complete_reverted": "Factura des-actualizada correctamente", + "complete_cove_validation": "Validacion de COVE completada", + "complete_default": "Proceso completado" + }, + "steps": { + "load_invoice": "Cargando factura", + "validate_invoice_data": "Validando datos de la factura", + "review_classes_exchange_rate": "Revisando clases y tipo de cambio", + "calculate_item_values": "Calculando valores por partida", + "validate_items": "Validando partidas", + "validate_rule8_quotas": "Validando cupos de Regla Octava", + "update_totals": "Actualizando totales", + "validate_invoice_status": "Validando estatus de la factura", + "verify_item_balances": "Verificando saldos de partidas", + "confirm_changes": "Confirmando cambios" + }, + "dialogs": { + "revert_title_export": "Des-actualizar Factura de Exportacion", + "revert_title_import": "Des-actualizar Factura de Importacion", + "revert_description_intro": "Se va a des-actualizar la factura", + "revert_description_warning": "Esta operacion revertira los registros de saldos/descargos generados al procesar la factura.", + "revert_description_question": "Desea continuar?", + "winsaai_title": "Sistema de Control de Aduanas e Inventarios", + "winsaai_description_intro": "A la Factura", + "winsaai_of_type": "de tipo", + "winsaai_description_process": "se le ha asignado el proceso Generacion del Archivo WINSAAI.", + "winsaai_description_question": "Desea Continuar o Cancelar?" + }, + "footer": { + "toolbar_aria": "Acciones de factura", + "invoice_pdf": "Factura PDF", + "invoice_csv": "Factura CSV", + "consolidated": "Consolidado", + "consolidated_notice": "Aviso Consolidado", + "packing_list": "Packing List", + "four_copies_rem": "4 Copias Rem", + "descargo_peps": "Descargo PEPS", + "transferencia_electronica": "Transferencia Electronica", + "interface_vu": "Interface VU", + "vu_options_keyboard": "Opciones VU (teclado)", + "vu_consult": "Consulta", + "vu_addenda": "Adenda", + "vu_cove_receipt": "Acuse de COVE", + "vu_massive_cove": "COVE Masivos", + "cons_sed": "Cons SED", + "encomienda": "Encomienda", + "fact_mex_cons": "Fact Mex Cons", + "fact_mex_ord_cat": "Fact Mex Ord Cat", + "export_sia": "Export SIA", + "interface": "Interface", + "process_update": "Actualizar", + "unprocess": "Desactualizar", + "view_details": "Ver Detalles", + "customs_broker_interface": "Interface Agente Aduanal", + "edit": "Editar", + "delete": "Eliminar" + }, + "submenu": { + "consult_soon": "Consulta VU - Proximamente", + "addenda_soon": "Adenda VU - Proximamente", + "massive_cove_soon": "COVE Masivos - Proximamente", + "generate_invoice_csv_soon": "Generar Factura CSV - Proximamente", + "four_copies_soon": "4 Copias Rem - Proximamente", + "cons_sed_soon": "Cons SED - Proximamente", + "encomienda_soon": "Encomienda - Proximamente", + "fact_mex_cons_soon": "Factura Mex Consolidada - Proximamente", + "fact_mex_ord_cat_soon": "Factura Mex Orden Captura - Proximamente", + "export_sia_soon": "Export SIA - Proximamente", + "interface_soon": "Interface - Proximamente" + }, + "recipients": { + "company_vu_email": "Correo VU de la empresa", + "company_main_email": "Correo principal de la empresa", + "company_industrial_1": "Correo industrial 1", + "company_industrial_2": "Correo industrial 2", + "company_description": "Empresa {name}", + "single_window_email": "Correo de ventanilla unica", + "main_email": "Correo principal", + "company_user_email": "Usuario de la empresa - {email}", + "my_email": "Mi correo", + "authenticated_user": "Usuario autenticado - {email}", + "load_error": "No se pudieron cargar los correos disponibles para COVE", + "no_configured": "No hay correos configurados para COVE" + }, + "toasts": { + "select_invoice_for_cove": "Selecciona una factura para generar COVE", + "no_company_selected": "No hay empresa seleccionada", + "session_expired_reloading": "Sesión expirada. Recargando página...", + "load_more_error": "Error cargando más datos", + "apply_filters_error": "Error aplicando filtros", + "reload_data_error": "Error recargando datos", + "download_start_error": "No se pudo iniciar la descarga", + "consolidated_download_start_error": "No se pudo iniciar la descarga del consolidado", + "calculating_peps": "Calculando asignacion PEPS...", + "peps_calculation_error_prefix": "Error al calcular PEPS: {error}", + "peps_calculation_completed": "Calculo PEPS completado", + "peps_report_start_error": "No se pudo iniciar la descarga del reporte PEPS", + "aviso_consolidado_start_error": "No se pudo iniciar la descarga del Aviso Consolidado", + "packing_list_start_error": "No se pudo iniciar la descarga del Packing List", + "fast_interface_import_only": "La interfaz rapida solo esta disponible para facturas de Importacion", + "customs_broker_interface_start_error": "No se pudo iniciar la generacion de Interface Agente Aduanal", + "pdf_download_success": "PDF descargado exitosamente", + "invoice_processed_success": "Factura procesada correctamente", + "worker_error_prefix": "El worker reporto un error: {error}", + "task_result_process_error": "Error al procesar el resultado de la tarea", + "select_invoice_to_edit": "Seleccione una factura para editar", + "no_table_rows": "No hay filas en la tabla", + "select_invoice_for_reports": "Seleccione una factura para reportes", + "select_invoice_for_more_actions": "Seleccione una factura para mas acciones", + "select_invoice_to_revert": "Seleccione una factura para desactualizar", + "select_invoice_for_details": "Seleccione una factura para ver detalles", + "select_invoice": "Seleccione una factura", + "select_at_least_one_invoice_to_delete": "Seleccione al menos una factura para eliminar", + "select_invoice_for_pdf": "Seleccione una factura para descargar PDF", + "select_invoice_for_consolidated": "Seleccione una factura para descargar consolidado", + "select_invoice_to_change_status": "Seleccione una factura para cambiar su estatus", + "update_status_error_prefix": "Error al {action} factura: {error}", + "status_action_update": "actualizar", + "status_action_revert": "desactualizar", + "status_updated_success": "Factura actualizada correctamente", + "status_reverted_success": "Factura desactualizada correctamente", + "update_status_unexpected_error": "Error inesperado al cambiar el estatus", + "select_invoice_to_process": "Selecciona una factura para procesar", + "process_start_error_prefix": "Error al iniciar el proceso: {error}", + "process_start_error": "No se pudo iniciar el proceso", + "revert_start_error_prefix": "Error al iniciar la des-actualizacion: {error}", + "revert_start_error": "No se pudo iniciar la des-actualizacion", + "select_recipient_email_for_cove": "Selecciona un correo para enviar el COVE", + "cove_eligibility_error_prefix": "No se pudo validar elegibilidad COVE: {error}", + "cove_requirements_not_met": "La factura no cumple los requisitos para generar COVE", + "cove_verification_error": "No se pudo verificar si la factura puede generar COVE", + "cove_start_error_prefix": "Error al iniciar generacion de COVE: {error}", + "cove_start_error": "No se pudo iniciar la generacion de COVE", + "validation_extra_more": "\n...y {count} mas", + "validation_error_count": "{count} error(es) de validacion:\n{preview}{extra}", + "cove_external_queued_default": "Factura COVE iniciada en Ventanilla Unica. Use el task_id para consultar el estado." + } + }, + "invoice_table": { + "no_results": "No hay resultados.", + "loading_more": "Cargando mas...", + "scroll_to_load_more": "Desplazate para cargar mas", + "processed": "Procesada", + "pending": "Pendiente", + "operation": "Operacion", + "operation_import": "Importacion", + "operation_export": "Exportacion", + "invoice_type": "Tipo Factura", + "invoice_number": "Num. Factura", + "pedimento_18": "Pedimento 18", + "remesa": "Remesa", + "invoice_date": "Fecha Factura", + "pedimento_code": "Clave Ped.", + "document_type": "Tipo Doc.", + "total_items": "Total Partidas", + "currency": "Moneda", + "currency_type": "Tipo Moneda", + "weight_type": "Tipo Peso", + "mixed": "Mixto", + "related_doc": "Doc. Relacionado", + "yes": "Si", + "no": "No", + "not_available_short": "N/D" + }, + "invoice_selectors": { + "identifier_catalog": { + "title": "Seleccionar Identificador", + "description": "Busca y selecciona un identificador del catalogo (Apendice 8).", + "search_placeholder": "Buscar por clave o descripcion...", + "column_code": "Clave", + "column_description": "Descripcion", + "column_level": "Nivel", + "empty": "No se encontraron identificadores." + }, + "valuation_method": { + "title": "Seleccionar Metodo de Valoracion", + "description": "Busca y selecciona un metodo de valoracion de la lista.", + "search_placeholder": "Buscar por clave o descripcion...", + "column_code": "Clave", + "column_description": "Descripcion", + "empty": "No se encontraron metodos de valoracion." + }, + "location": { + "title": "Catalogo de ubicaciones (maquinaria y equipo)", + "no_company_selected": "No hay compania seleccionada", + "load_error": "Error al cargar ubicaciones", + "required_key": "La clave es requerida", + "save_error": "Error al guardar", + "key_label": "Clave *", + "key_placeholder": "Clave de localizacion", + "location_label": "Localizacion", + "location_placeholder": "Nombre o descripcion", + "department_label": "Departamento", + "responsible_label": "Responsable", + "observations_label": "Observaciones", + "optional_placeholder": "Opcional", + "back_to_list": "Volver al listado", + "save": "Guardar", + "search_placeholder": "Buscar por clave o localizacion...", + "register_new": "Registrar nueva ubicacion", + "column_key": "Clave", + "column_location": "Localizacion", + "no_results": "No se encontraron resultados", + "cancel": "Cancelar" + }, + "tariff_fraction": { + "title": "CATALOGO DE FRACCIONES SITAR - SCAII", + "search_label": "Buscando:", + "search_placeholder": "Buscar por fraccion, descripcion, NICO...", + "column_key": "Clave", + "column_fraction": "Fraccion", + "column_nico": "NICO", + "column_description": "Descripcion", + "column_umt": "U.M.T", + "column_adv_impo": "Adv. Impo", + "column_adv_expo": "Adv. Expo", + "column_dof": "DOF", + "column_aplica_ieps": "Aplica IEPS", + "loading": "Cargando fracciones...", + "empty": "No hay fracciones disponibles", + "cancel": "Cancelar" + }, + "us_tariff_fraction": { + "no_company_selected": "No hay empresa seleccionada", + "load_error_prefix": "Error: {error}", + "no_records_info": "No se encontraron fracciones US registradas", + "connection_error_prefix": "Error de conexion: {error}", + "title": "Seleccionar Fracción US", + "description": "Seleccione la fraccion arancelaria (HTS) del catalogo.", + "search_placeholder": "Buscar por codigo o descripcion...", + "loading_catalog": "Cargando catalogo...", + "no_results": "No se encontraron fracciones.", + "column_code": "Codigo (HTS)", + "column_description": "Descripcion", + "records_found": "{count} registros encontrados", + "cancel": "Cancelar" + }, + "invoice_selector_modal": { + "no_active_company": "No se ha seleccionado una empresa activa", + "search_error": "Error al buscar facturas", + "title_export": "Facturas de Exportacion", + "title_import": "Facturas de Importacion ({regimen})", + "description_export": "Selecciona una factura del catalogo para vincularla a la partida.", + "description_import": "Selecciona una factura de importacion procesada para el regimen {regimen}.", + "search_placeholder": "Buscar por numero de factura...", + "searching_button": "Buscando...", + "search_button": "Buscar", + "searching_available": "Buscando facturas disponibles...", + "no_invoices": "No se encontraron facturas", + "try_other_filter": "Intenta con otro numero de factura o filtro", + "processed_badge": "Procesada", + "pedimento_label": "Pedimento", + "no_date": "Sin fecha", + "not_available_short": "N/D", + "select": "Seleccionar", + "total_found": "Total: {count} facturas encontradas", + "close": "Cerrar" + }, + "port_selector": { + "title": "Seleccionar Puerto (Aduana/Sección)", + "description": "Busca y selecciona una sección aduanera de la lista.", + "search_placeholder": "Buscar por código o nombre...", + "column_code": "Código", + "column_name": "Nombre / Sección", + "loading": "Cargando secciones aduaneras...", + "empty": "No se encontraron resultados", + "cancel": "Cancelar" + }, + "manifest_selector": { + "title": "Seleccionar Manifiesto", + "description": "Busca y selecciona un manifiesto del catálogo de exportación para vincular a esta factura.", + "search_placeholder": "Buscar por número...", + "search_button": "Buscar", + "searching": "Buscando manifiestos...", + "column_number": "Número de Manifiesto", + "column_description": "Descripción", + "empty": "No se encontraron resultados" + } + }, + "invoice_edit": { + "new_title": "Nueva Factura", + "edit_title": "Editar Factura", + "new_description": "Ingresa los datos de la nueva factura", + "edit_description": "Modifica los datos de la factura", + "draft_badge": "Borrador", + "saved_success": "Todos los cambios se guardaron correctamente", + "invoice_number_prefix": "Número:", + "edit_details": "Edita los detalles de la factura", + "page_invoice_prefix": "Factura #", + "page_default_values_loaded_prefix": "Valores predeterminados cargados para {invoiceType}", + "page_save_error_prefix": "Error al guardar la factura", + "page_save_changes_error": "Error al guardar los cambios", + "page_console_hint": "Revisa la consola para más detalles", + "page_session_expired": "Sesión expirada. Recargando página...", + "tabs": { + "general": "General", + "compliance": "Cumplimiento", + "financials": "Financieros", + "observations": "Observaciones", + "items": "Partidas", + "others": "Otros", + "continuation": "Cont." + }, + "form": { + "operation_type_label": "Tipo de Operación *", + "operation_type_placeholder": "Seleccionar tipo", + "operation_type_import": "Importación", + "operation_type_export": "Exportación", + "invoice_number_label": "Número de Factura", + "invoice_number_placeholder": "Número de factura", + "invoice_type_label": "Tipo de Factura", + "invoice_type_placeholder": "Tipo de factura", + "no_company_selected": "No hay compañía seleccionada", + "exchange_rate_required": "El tipo de cambio es requerido (pestaña Financieros)", + "exchange_rate_positive": "El tipo de cambio debe ser mayor a 0 (pestaña Financieros)", + "save_error": "Error al guardar", + "loading_defaults_prefix": "Valores predeterminados cargados para", + "pedimento_pending": "¿Pedimento pendiente?", + "pedimento_label": "Pedimento", + "pedimento_placeholder": "Selecciona pedimento...", + "remesa_label": "Remesa", + "invoice_number_label_short": "Núm. Factura", + "invoice_date_label_exp": "Fecha", + "invoice_date_label_mex": "Fecha de Entrada", + "invoice_date_label_default": "Fecha Factura", + "emission_date_label": "Fecha Emisión", + "iva_factor_label": "Factor IVA", + "alternate_invoice_label": "Factura Alterna", + "project_number_label": "Número de Proyecto", + "project_number_placeholder": "Número de proyecto", + "purchase_order_label": "Orden de Compra", + "purchase_order_placeholder": "Orden de compra", + "invoice_date_label": "Fecha de Factura", + "validation": { + "trailer_required": "El Remolque es obligatorio cuando el Tipo de Transporte es distinto de Ninguno.", + "missing_fields": "Los siguientes campos son obligatorios:", + "check_transport_data": "Revisa los datos de transporte y logística", + "save_error": "Error al guardar los cambios" + }, + "traffic_light_status_label": "Semáforo", + "traffic_light_status_placeholder": "Estado del semáforo", + "observation_es_label": "Observaciones (Español)", + "observation_es_placeholder": "Observaciones en español", + "observation_en_label": "Observaciones (Inglés)", + "observation_en_placeholder": "Observaciones en inglés", + "remesa_placeholder": "Número de remesa", + "aduana_label": "Aduana", + "aduana_placeholder": "Código de aduana", + "customs_broker_label": "Agente Aduanal", + "customs_broker_placeholder": "ID del agente aduanal", + "provider_label": "Proveedor", + "provider_placeholder": "ID del proveedor", + "edocument_label": "E-Document", + "edocument_placeholder": "Número de e-document", + "is_mixed_label": "Operación Mixta", + "currency_placeholder": "MXN, USD, etc.", + "exchange_rate_placeholder": "Tipo de cambio", + "value_mn_label": "Valor MN", + "value_mn_placeholder": "Valor en moneda nacional", + "value_me_label": "Valor ME", + "value_me_placeholder": "Valor en moneda extranjera", + "customs_value_mn_label": "Valor Aduana MN", + "customs_value_mn_placeholder": "Valor de aduana en MN", + "freight_label": "Flete", + "freight_placeholder": "Costo de flete", + "insurance_label": "Seguro", + "insurance_placeholder": "Costo de seguro", + "iva_mn_label": "IVA MN", + "iva_mn_placeholder": "IVA en MN", + "total_quantity_label": "Cantidad Total", + "total_quantity_placeholder": "Cantidad total", + "gross_weight_label": "Peso Bruto", + "gross_weight_placeholder": "Peso bruto", + "net_weight_label": "Peso Neto", + "net_weight_placeholder": "Peso neto", + "bundle_count_label": "Número de Bultos", + "bundle_count_placeholder": "Número de bultos", + "update_button": "Actualizar", + "create_button": "Crear" + }, + "general": { + "pedimento_section": "Datos del pedimento", + "pedimento_date_from": "Fecha del:", + "pedimento_date_to": "Fecha al:", + "pedimento_code": "Clave:", + "pedimento_regimen": "Régimen:", + "clients_suppliers_broker": "Clientes - Proveedores - Agente Aduanal", + "provider_header_supplier": "Proveedor", + "provider_header_exporter": "Exportador", + "sold_to_header_consignado": "Consignado a", + "sold_to_header_vendido": "Vendido a", + "sold_to_header_exportado": "Exportado a", + "sold_to_header_importador": "Importador", + "shipped_to_header_enviado": "Enviado a", + "shipped_to_header_transferido": "Transferido a", + "shipped_to_header_donado": "Donado a", + "shipped_to_header_importador": "Importador", + "shipped_by_header_enviado_por": "Enviado Por", + "shipped_by_header_destinatario": "Destinatario", + "shipped_by_header_vendido_por": "Vendido Por", + "shipped_by_header_notificar": "Notificar a", + "select_header_placeholder": "Selecciona encabezado...", + "select_placeholder": "Selecciona...", + "select_broker_placeholder": "Selecciona...", + "broker_mex_label": "Agente Aduanal Mex:", + "broker_usa_label": "Agente Aduanal US:", + "currency_weight_section": "Tipo de Moneda - Pesos Netos y Brutos", + "exchange_rate": "Tipo de cambio:", + "currency_foreign": "Extranjera (Dlls)", + "currency_local": "Nacional (Pesos)", + "currency_manual": "De Captura", + "currency_label": "Moneda:", + "weight_type_label": "Tipo Peso:", + "weight_type_kgs": "Kilogramos (kg)", + "weight_type_lbs": "Libras (lb)", + "manifest_number_label": "Num. de Manifiesto:", + "manifest_placeholder": "Manifiesto...", + "transport_section": "Transportista", + "transport_label": "Transportista:", + "transport_key_label": "Clave Transporte:", + "transport_type_label": "Tipo Transporte:", + "trailer_label": "Remolque:", + "driver_label": "Conductor:", + "iva_label": "IVA:", + "customs_label": "Aduana y Sección de Despacho:", + "document_type_label": "Clave de Régimen Aduanero:", + "select_transporter_placeholder": "Selecciona transportista...", + "select_vehicle_placeholder": "Selecciona vehículo...", + "select_driver_placeholder": "Selecciona conductor...", + "select_trailer_placeholder": "Selecciona remolque...", + "select_customs_placeholder": "Selecciona aduana...", + "select_regimen_placeholder": "Selecciona régimen...", + "choose_transporter_first": "Primero elige transportista...", + "no_data": "Sin datos", + "no_drivers_for_transporter": "Sin conductores para este transportista", + "no_regimens_for_operation": "Sin regímenes para tipo", + "choose_operation_first": "Selecciona tipo de operación primero", + "transport_none": "Ninguno", + "transport_type_transport": "Transporte", + "transport_type_box": "Caja", + "transport_type_licence_plates": "Placas", + "transport_type_truck": "Camión", + "transport_type_vessel": "Buque", + "transport_type_rail_barge": "Ferrobarcaza", + "transport_type_container": "Contenedor", + "transport_type_airplane": "Avión", + "transport_type_gondola": "Góndola", + "transport_type_flatbed": "Plataforma", + "signature_label": "Firma Electrónica:", + "general_info": "Información General" + }, + "page": { + "saving_all_changes": "Guardando todos los cambios...", + "save_all_changes": "Guardar Todos los Cambios", + "cancel": "Cancelar" + }, + "observations": { + "mexican_observation": "Observaciones de la factura mexicana:", + "bilingual_observation": "Observación de la factura mexicana y bilingüe:", + "textarea_placeholder": "Escribe tus observaciones aquí.", + "fixed_legend": "Leyenda fija:", + "selected_legend_prefix": "Clave", + "select_legend_placeholder": "Selecciona leyenda...", + "add_to_observations": "Agregar a observaciones", + "american_observation": "Observaciones de la factura US:", + "identifiers_title": "Identificadores", + "first_label": "Primero:", + "second_label": "Segundo:", + "key_placeholder": "Clave...", + "complements_title": "Complementos", + "one_label": "1:", + "two_label": "2:", + "office_label": "Oficio:", + "incrementables_title": "Incrementables:", + "freight_label": "Flete:", + "insurance_label": "Seguros:", + "packaging_label": "Embalajes:", + "other_increments_label": "Otros increm.:", + "other_deductibles_label": "Otros deduc.:", + "seal_number_label": "Número de Precinto:", + "movement_type_label": "Tipo Movimiento:", + "alternate_invoice_label": "Factura Alterna:", + "proforma_number_label": "Número de Proforma:", + "subdivision_label": "Sub División:", + "yes": "Sí", + "no": "No", + "acts_as_cd_label": "Funge como CD:", + "incoterm_label": "Incoterm:", + "select_placeholder": "Selecciona...", + "valuation_method_label": "Método de Valoración:", + "mixed_label": "¿Es mixto?", + "seal_count_label": "Num Precintos:", + "delivery_title": "Datos Entrega", + "delivered_label": "Entregado", + "received_by_label": "Recibido por:", + "delivery_date_label": "Fecha Entrega:", + "rule_parties_label": "Regla 3.1.21 Partes II", + "status_comment_label": "Comentario Estatus:", + "status_comment_placeholder": "Comentario estatus", + "related_docs_label": "ID Relación Docs:", + "electronic_signature_label": "Firma Electrónica:", + "authorized_person_label": "Mandatario/Persona Autorizada:", + "contingency_mode_label": "Modo Contingencia", + "cove_label": "COVE:", + "operation_number_label": "Núm Operación:", + "adendas_label": "Adenda(s):", + "vu_observations_label": "Observaciones VU:", + "load_info": "Cargar Info.", + "entry_exit_date_label": "Fecha Entrada/Salida:", + "payment_date_label": "Fecha Pago:", + "certificate_number_label": "Número Certificado:", + "enclosure_label": "Recinto:", + "alternate_flags_title": "Factura Alterna & Flags", + "valuation_method_placeholder": "Selecciona...", + "mixed_label_short": "Es mixto?", + "errors_title": "Errores de Facturación", + "line": "Línea", + "key": "Clave", + "description": "Descripción", + "no_errors": "Sin errores registrados", + "insert": "Insertar", + "edit": "Editar", + "delete": "Borrar" + }, + "others": { + "transport_mode_label": "Modo de Transporte:", + "select_mode_placeholder": "Seleccionar modo", + "print_stamp_label": "Imprimir el Sello por Valor menor a 2500 dlls", + "mixed_label": "Es Mixto?", + "yes": "Sí", + "no": "No", + "master_bol_label": "Número Master BOL:", + "guide_number_label": "Número Guía:", + "shipment_number_label": "Número Embarque:", + "option_iv18_label": "Opción IV 18:", + "select_option_placeholder": "Seleccionar opción", + "delivery_title": "Datos Entrega", + "delivered_label": "Entregado", + "received_by_label": "Recibido por:", + "delivery_date_label": "Fecha Entrega:", + "rule_3121_label": "Regla 3.1.21 Partes II", + "status_comment_label": "Comentario Estatus:", + "status_comment_placeholder": "Comentario estatus", + "related_docs_label": "ID Relación Docs:", + "electronic_signature_label": "Firma Electrónica:", + "authorized_person_label": "Mandatario/Persona Autorizada:", + "contingency_mode_label": "Modo Contingencia", + "cove_label": "COVE:", + "operation_number_label": "Núm Operación:", + "adendas_label": "Adenda(s):", + "vu_observations_label": "Observaciones VU:", + "load_info": "Cargar Info.", + "entry_exit_date_label": "Fecha Entrada/Salida:", + "payment_date_label": "Fecha Pago:", + "certificate_number_label": "Número Certificado:", + "electronic_signature_2_label": "Firma Electrónica:", + "errors_title": "Errores de Facturación", + "line": "Línea", + "key": "Clave", + "description": "Descripción", + "no_errors": "Sin errores registrados", + "insert": "Insertar", + "edit": "Editar", + "delete": "Borrar" + }, + "items": { + "unsaved_invoice_title": "Factura no guardada", + "unsaved_invoice_description": "Debes guardar la factura primero antes de agregar partidas.", + "loaded_more_items": "Cargando más items...", + "deleted": "Partida eliminada", + "delete_failed": "No se pudo eliminar la partida", + "no_data_to_save": "No hay datos para guardar", + "required_fields": "Completa los campos necesarios (Clase o Descripción)", + "no_active_company": "No hay ID de empresa activo. Asegúrate de tener una empresa seleccionada.", + "no_invoice_id": "No hay ID de factura. La factura debe ser guardada antes de agregar partidas.", + "update_failed": "No se pudo actualizar la partida", + "updated": "Partida actualizada", + "create_failed": "No se pudo crear la partida", + "created": "Partida creada", + "save_error": "Error al guardar", + "saved_to_template": "Partida guardada en plantilla", + "save_invoice_first": "Primero guarda la factura para usar plantillas.", + "use_template_description": "Selecciona una plantilla predefinida para cargar sus partidas.", + "refresh": "Actualizar", + "search_templates_placeholder": "Buscar plantillas...", + "loading": "Cargando...", + "template_applied": "Plantilla aplicada", + "apply_template_error": "Error al aplicar plantilla", + "template_saved": "Plantilla guardada", + "save_template_error": "Error al guardar plantilla", + "title": "Items de la Factura", + "subtitle": "Carga partidas, crea o aplica plantillas sin salir de esta vista.", + "use_template": "Usar plantilla", + "create_template": "Crear plantilla", + "add_items": "Agregar Partidas", + "cancel": "Cancelar", + "applying": "Aplicando...", + "apply_template": "Aplicar Plantilla", + "create_template_dialog_title": "Crear plantilla", + "create_template_dialog_description": "Guarda los elementos actuales como una plantilla reutilizable para inyectar en otras partidas.", + "template_name_label": "Nombre de la Plantilla", + "template_name_placeholder": "Ej. Paquete estándar de refacciones", + "template_description_label": "Descripción", + "template_description_placeholder": "Indica para qué sirve esta plantilla...", + "template_items_count": "items/líneas", + "template_items_title": "Items de la plantilla", + "add_item_line": "Agregar Item/Línea", + "template_table_hash": "#", + "template_table_description": "Descripción", + "template_table_quantity": "Cant.", + "template_table_actions": "Acciones", + "template_empty": "Usa el botón \"Agregar Item/Línea\" para definir el contenido de la plantilla.", + "no_description": "Sin descripción", + "no_description_short": "Sin descripción disponible.", + "no_description_available": "Sin descripción disponible.", + "no_templates_found": "No se encontraron plantillas", + "select_template_to_view": "Selecciona una plantilla para ver sus detalles", + "created_label": "Creada", + "item_description": "Descripción del Item", + "quantity_short": "Cant.", + "quantities": "Cantidades:", + "template_empty_items": "Esta plantilla no contiene items.", + "imported_quantity": "Cant. Importada", + "reference": "Ref:", + "saving": "Guardando...", + "save_template": "Guardar plantilla", + "column_line": "Línea", + "column_impo_invoice": "Factura Impo", + "column_ps": "P/S", + "column_class": "Clase", + "column_part_number": "Número Parte", + "column_description": "Descripción", + "column_has_subitem": "Contiene Subpartida", + "column_main_item": "Partida Principal", + "column_class_description": "Descripción Clase", + "column_um": "U.M.", + "column_preference": "Preferencia", + "column_quantity": "Cantidad", + "column_actions": "Acciones", + "no_items_available": "No hay items disponibles", + "showing_lines": "Mostrando {displayed} de {total} líneas", + "spanish_description_label": "Descripción en español:", + "select_row_to_view_description": "Selecciona una fila para ver la descripción.", + "bultos": "Bultos:", + "imported": "Importada:", + "net_weight": "Peso neto:", + "gross_weight": "Peso bruto:", + "import_values_title": "Valores de importación:", + "dollars": "Dólares:", + "pesos": "Pesos:", + "capture_value": "De Captura:", + "customs_value_short": "Aduana:", + "error_panel_title_with_count": "Errores ({count})", + "warning_panel_title": "Advertencia", + "error_panel_fallback_message": "No pudimos guardar los cambios. Revisa la información e intenta de nuevo.", + "error_panel_clear": "Limpiar", + "error_panel_column_type": "Tipo", + "error_panel_column_field": "Campo", + "error_panel_column_message": "Mensaje", + "error_panel_empty_field": "—", + "error_panel_dismiss_row_aria": "Quitar este error", + "error_panel_toggle_details_aria": "Mostrar u ocultar el detalle de errores", + "inline_notice_close_aria": "Cerrar aviso" + } + }, + "invoice_item_fa": { + "item_sheet": { + "tab_general": "Generales", + "tab_identifiers": "Identificadores", + "not_available_short": "N/D" + }, + "repair": { + "generate_discharge": "Genera Descarga?", + "export_invoice_label": "Factura de Expo", + "export_line_label": "Línea de Expo", + "type_search_label": "Tipo Búsqueda", + "import_type_label": "Tipo Importación:", + "import_invoice_label": "Factura Impo", + "line_label": "Línea", + "loading_line": "Cargando...", + "search_placeholder": "Seleccionar...", + "temporal": "TEM (Temporal)", + "definitive": "DEF (Definitiva)", + "loading_item_data": "Cargando datos de la partida...", + "close": "Cerrar", + "cancel": "Cancelar", + "save": "Guardar", + "select_line_title": "Seleccionar línea", + "import_title": "Partidas de Importación", + "import_description": "Selecciona una línea con saldo disponible para realizar la descarga.", + "loading_invoice_items": "Cargando partidas de la factura...", + "no_balance": "Sin saldo disponible", + "no_balance_description": "No hay líneas con saldo en esta factura para descargar.", + "no_description": "Sin descripción" + }, + "main_data": { + "legend": "Datos principales", + "quantity": "Cantidad", + "unit_cost": "Costo unitario", + "total_value": "Valor total", + "tariff_type": "Tipo arancelario" + }, + "packages": { + "legend": "Bultos", + "quantity": "Cantidad", + "package_code": "Clave bulto", + "weight": "Peso", + "description": "Descripcion", + "weights": "Pesos", + "net": "Neto", + "gross": "Bruto", + "space": "Espacio", + "permit_number": "Num. permiso", + "page_region": "Pag/Region", + "american_fraction": "Fracción US", + "brand": "Marca", + "model": "Modelo", + "purchase_order": "Orden de compra" + }, + "summary": { + "general_data": "DATOS GENERALES", + "return_quantity_subitems": "CANTIDAD DE RETORNO SUBPARTIDAS", + "temporary": "Temporal", + "replacement_or_change": "Reemplazo o cambio", + "definitive": "Definitiva", + "returned_values": "Valores retornados", + "weights_kilos": "PESOS (KILOS)", + "weights_pounds": "PESOS (LIBRAS)", + "net": "Neto", + "gross": "Bruto", + "costs_values": "COSTOS Y VALORES", + "dollars": "(Dolares)", + "pesos": "(Pesos)", + "cost": "Costo", + "value": "Valor", + "customs_value": "Valor aduana", + "capture_cost": "Costo captura", + "capture_value": "Valor captura" + }, + "continuation": { + "tax_paid": "IMPUESTO PAGADO", + "yes": "Si", + "no": "No", + "general_info": "Información General", + "transport_number_type": "Número/Tipo de Transporte:", + "vehicle_data": "Datos Vehículo:", + "is_rail": "Es Ferrocarril?", + "bill_number": "Número BL:", + "guide_count": "Cantidad de Guías de Embarque (BL):", + "destination_origin": "Destino/Origen:", + "destination_origin_placeholder": "FRANJA FRONT.", + "is_mixed": "Es Mixto?", + "entry_port": "Puerto Entrada:", + "export_reason": "Razón de exportación:", + "reason_sold": "Vendido", + "reason_not_sold": "No Vendido", + "reason_other": "Otro", + "payment_terms": "Términos de Pago:", + "handling_fees": "Maniobras (Handlings):", + "reviewed_equipment": "Fue Revisado el Equipo", + "subdivision": "Sub División", + "acts_as_cd": "Funge Como CD", + "pedimento_arrived": "Llegó el Pedimento", + "billing_errors": "Errores de Facturación", + "error_line": "Línea", + "error_key": "Clave", + "error_description": "Descripción", + "no_errors": "Sin errores registrados", + "insert": "Insertar", + "edit": "Editar", + "delete": "Borrar", + "traffic_light": "Semáforo", + "green_mx": "Verde MX", + "green_usa": "Verde USA", + "red_mx": "Rojo MX", + "red_usa": "Rojo USA", + "cfdi_data_title": "DATOS CFDI", + "cfdi_uuid_label": "CFDI UUId:", + "cfdi_pdf_label": "CFDI Path PDF:", + "cfdi_xml_label": "CFDI Path XML:", + "payment_method": "Forma de pago", + "igi_amount": "Monto IGI", + "dollars": "DOLARES", + "igi_payment_method": "Forma de pago IGI", + "has_fda_code": "Tiene clave FDA", + "has_certificate_of_origin": "Tiene certificado de origen?", + "certificate_number": "Num. certificado de origen", + "end_date": "Fecha fin", + "machinery_equipment_location": "Ubicacion de maquinaria y equipo", + "location_variable": "Variable de ubicacion", + "military_equipment_enable": "Habilitar si la partida contiene equipo militar", + "own_equipment": "Equipo propio", + "omit_annex31": "Omitir anexo 31", + "lot": "Lote", + "entry_number": "Num. entrada", + "eighth_rule_permit": "Permiso regla octava", + "eighth_rule_fraction": "Fraccion regla octava", + "line": "Linea", + "consider_a31": "Considerar en A31", + "extra_description_spanish": "Descripcion adicional en espanol" + }, + "configuration": { + "is": "Es", + "item": "Partida", + "subitem": "Subpartida", + "contains_subitems": "Contiene subpartidas", + "yes": "Si", + "main_item_number": "Numero de partida principal", + "main_item_number_placeholder": "Captura numero de partida principal", + "description_spanish": "Descripcion en espanol", + "description_english": "Descripcion en ingles" + }, + "labeling": { + "legend": "Etiquetado y Valoracion", + "label_number": "Numero de etiqueta", + "label_type": "Tipo de etiqueta", + "observations": "Observaciones", + "observations_placeholder": "Observaciones de etiquetado...", + "assets_series": "Activos / Series", + "asset_number_short": "Num. activo", + "actions_short": "Acc.", + "asset_number": "Numero de activo", + "cancel": "Cancelar", + "save": "Guardar" + }, + "identifiers": { + "asset_number": "Numero de activo", + "asset_tag_title": "Etiqueta de activo" + }, + "dialogs": { + "countries_load_error": "Error al cargar paises", + "states_load_error": "Error al cargar estados", + "packages_load_error": "Error al cargar bultos", + "units_load_error": "Error al cargar unidades de medida", + "payment_methods_load_error": "Error al cargar formas de pago" + }, + "invoice_item_inv": { + "edit_title": "Editar Item", + "add_title": "Agregar Nuevo Item", + "edit_description": "Modifica los campos del inventario y guarda los cambios.", + "add_description": "Completa la información del nuevo item de inventario.", + "line_prefix": "Línea", + "required_fields_hint": "Los campos marcados con * son obligatorios.", + "tab_general": "General", + "tab_classification": "Clasificación", + "tab_quantities": "Cantidades", + "tab_other": "Otros", + "invoice_info_title": "Información de la Factura", + "invoice_unsaved_warning": "Esta factura aún no se ha guardado. Los items se asociarán cuando guardes la factura.", + "invoice_id": "ID Factura:", + "operation_type": "Tipo Operación:", + "invoice_number": "Número de Factura:", + "system": "Sistema:", + "class_label": "Clase", + "select_class_placeholder": "Selecciona una clase", + "quantity_label": "Cantidad", + "unit_label": "U.M.", + "select_unit_placeholder": "Selecciona U.M.", + "unit_cost_label": "Costo Unitario", + "country_label": "País de Origen", + "select_country_placeholder": "Selecciona país", + "fraction_label": "Fracción", + "select_fraction_placeholder": "Selecciona fracción", + "tariff_type_label": "Tipo de Tarifa", + "reference_number_label": "Número de Referencia", + "purchase_order_label": "Orden de Compra/Venta", + "warehouse_label": "Almacén", + "location_label": "Ubicación", + "description_es_label": "Descripción (Español)", + "description_es_placeholder": "Descripción en español", + "description_en_label": "Descripción (Inglés)", + "description_en_placeholder": "Description in English", + "sku_label": "SKU", + "sku_placeholder": "Código SKU del producto", + "batch_label": "Lote", + "batch_placeholder": "Número de lote", + "classification_fraction_label": "Fracción Arancelaria", + "fraction_digits_placeholder": "8 dígitos", + "product_type_label": "Tipo de Producto", + "product_type_placeholder": "Materia prima, producto terminado, etc.", + "material_type_label": "Tipo de Material", + "material_type_placeholder": "Metal, plástico, etc.", + "product_code_label": "Código de Producto", + "product_code_placeholder": "Código interno", + "country_origin_label": "País de Origen", + "country_code_placeholder": "Código del país", + "merchandise_category_label": "Categoría de Mercancía", + "merchandise_category_placeholder": "Categoría", + "quantity_tab_label": "Cantidad", + "unit_of_measure_label": "Unidad de Medida", + "unit_of_measure_placeholder": "PZA, KG, M, etc.", + "zero_placeholder": "0", + "decimal_placeholder": "0.00", + "net_weight_label": "Peso Neto (KG)", + "gross_weight_label": "Peso Bruto (KG)", + "unit_cost_usd_label": "Costo Unitario (USD)", + "total_value_label": "Valor Total (USD)", + "packages_label": "Número de Bultos", + "package_type_label": "Tipo de Empaque", + "package_type_placeholder": "Caja, pallet, etc.", + "imported_quantity_label": "Cantidad Importada", + "remaining_quantity_label": "Cantidad Remanente", + "brand_label": "Marca", + "brand_placeholder": "Marca del producto", + "expiration_date_label": "Fecha de Caducidad", + "production_date_label": "Fecha de Producción", + "min_stock_label": "Stock Mínimo", + "max_stock_label": "Stock Máximo", + "observations_label": "Observaciones", + "observations_placeholder": "Notas adicionales sobre el inventario...", + "loading_item_data": "Cargando datos de la partida...", + "loading_more_items": "Cargando más items...", + "invoice_line_info": "Información de la factura ({systemLabel})", + "select_line": "Seleccionar línea", + "import_title": "Partidas de Importación", + "import_description": "Selecciona una línea con saldo disponible para realizar la descarga.", + "loading_invoice_items": "Cargando partidas de la factura...", + "no_balance": "Sin saldo disponible", + "no_balance_description": "No hay líneas con saldo en esta factura para descargar.", + "balance_required": "Línea con saldo disponible", + "cancel": "Cancelar", + "close": "Cerrar", + "saving": "Guardando...", + "update": "Guardar", + "create": "Guardar" + }, + "prerequisites": { + "title": "Aviso", + "message_both": "No hay Agentes aduanales ni Clientes registrados. Debes darlos de alta para poder trabajar en este módulo.", + "message_agents": "No hay Agentes aduanales registrados. Debes darlos de alta para poder trabajar en este módulo.", + "message_clients": "No hay Clientes registrados. Debes darlos de alta para poder trabajar en este módulo.", + "register_hint": "Puedes registrarlos en", + "agents_link": "Agentes Aduanales", + "clients_link": "Clientes y Proveedores", + "and": "y", + "cancel": "Cancelar", + "accept": "Aceptar" + } + }, + "csv_upload": { + "page_title": "Importación CSV", + "intro_help": "Clic izquierdo: cargar archivo CSV. Clic derecho: descargar plantilla.", + "tab_catalogos": "Catálogos", + "tab_transportes": "Transportes", + "tab_importacion": "Importación", + "tab_exportacion": "Exportación", + "section_catalogs": "Catalogos Generales", + "section_transport": "Transportes", + "section_import": "Operaciones de importación", + "section_export": "Operaciones de exportación", + "params_header": "Parámetros globales", + "config_prefix": "Configuración", + "soon": "Próximamente", + "drop_here": "¡Suelta el archivo!", + "groups": { + "permisos": "Permisos", + "impo_temp": "Impo. temp.", + "impo_def": "Impo. def.", + "cmex": "Compras mex.", + "expo_def": "Expo. def./Cam. reg.", + "expo_rep": "Expo. rep.", + "manifest": "Manifiesto" + }, + "items": { + "customs_brokers": "Agentes Aduanales", + "clients_providers": "Clientes y Proveedores", + "exchange_rates": "Tipos de cambio", + "material_classes": "Clases", + "part_numbers": "Partes", + "boms": "BOMs", + "items": "Partidas (permisos)", + "headers": "Encabezados (permisos)", + "historical_fractions": "Fracciones históricas", + "pedimentos": "Pedimentos", + "transporters": "Transportistas", + "transports": "Vehículos", + "drivers": "Conductores", + "trailers": "Trailers", + "imp_temp_header": "Encabezado", + "imp_temp_details": "Partidas", + "imp_temp_series": "Series", + "imp_def_header": "Encabezado", + "imp_def_details": "Partidas", + "imp_def_series": "Series", + "comp_mex_header": "Encabezado", + "comp_mex_details": "Partidas", + "comp_mex_series": "Series", + "exp_def_header": "Encabezado", + "exp_def_details": "Partidas", + "exp_def_series": "Series", + "exp_def_nodes": "NODES", + "exp_rep_header": "Encabezado", + "exp_rep_details": "Partidas", + "exp_rep_series": "Series", + "manifest_header": "Encabezado" + }, + "params": { + "load_mode": "Modo de carga", + "date_format": "Formato de fecha", + "weight_unit": "Unidad de peso", + "autonumber_series": "Autonumerar partidas/series", + "load_subpartidas": "Levantar subpartidas", + "recalculate_pedimento_date": "Recalcular fecha pedimento", + "autonumber_remesas": "Autonumerar remesas", + "recalculate_dates": "Recalcular fechas", + "invoice_type": "Tipo de factura", + "is_regime_change": "Es cambio de régimen" + }, + "options": { + "update": "Actualizar", + "replace": "Reemplazar", + "yes": "Sí", + "no": "No", + "kgs": "Kilos (kg)", + "lbs": "Libras (lb)", + "date_dd_mm": "DD/MM/YYYY", + "date_mm_dd": "MM/DD/YYYY", + "date_iso": "YYYY-MM-DD", + "afi": "AFIJO", + "normal": "NORMAL" + }, + "progress": { + "upload": "Subiendo archivo CSV", + "scan": "Validando registros en el servidor", + "commit": "Grabando registros en base de datos", + "upload_known": "Subiendo archivo…", + "upload_unknown": "Subiendo archivo (tamaño desconocido en el navegador)…", + "in_progress": "En proceso…", + "resume_hint": "Reanudando la importación guardada en esta pestaña…", + "rows_file": "Archivo: ~{n} fila(s) de datos — subiendo (aún no se validan registros en servidor)…", + "rows_scan": "Registros procesados: {current} / {total}", + "rows_commit": "Registros grabados: {current} / {total}", + "rows_commit_fallback": "Grabando en base de datos… ({current} / {total} según último total conocido)" + }, + "toast": { + "invalid_csv": "Formato inválido. Solo se permiten archivos .csv", + "download_loading": "Descargando plantilla…", + "download_ok": "Plantilla descargada.", + "download_err": "Error al descargar la plantilla", + "upload_err": "Error al subir el archivo", + "upload_err_generic": "Error inesperado al subir el archivo", + "scan_done": "Escaneo completado. Revisa los resultados.", + "import_done": "Importación completada. Revisa el listado de registros.", + "import_maybe_done": "La importación pudo completarse. Revisa el listado de registros.", + "stale_job": "Esta importación ya no está disponible (sesión expirada o trabajo eliminado). Puedes iniciar una nueva carga.", + "poll_err": "Error al consultar el estado", + "commit_err": "Error al iniciar la importación", + "scan_alt": "El escaneo terminó. Si no ves el modal, revisa el listado de registros.", + "finished_none": "No se insertaron registros. Revisa los errores a continuación.", + "commit_warning_ok": "{inserted} insertados, {updated} actualizados. {skipped} rechazados.", + "commit_warning_none": "No se insertaron ni actualizaron registros. {skipped} rechazados.", + "success_counts": "Importación completada: {msg}", + "warn_skipped": "{n} registros fueron rechazados u omitidos", + "error_processing": "Error en el procesamiento: {msg}", + "n_inserted": "{n} insertados", + "n_updated": "{n} actualizados", + "err_fetch_scan_result": "Error al obtener el resultado. Revisa el modal de resultados.", + "err_unknown": "Error desconocido", + "err_processing_fallback": "Error en el procesamiento. Revisa el modal o los detalles." + }, + "pending": { + "badge": "Pendientes", + "title": "Importaciones pendientes de confirmar", + "description": "Escaneos listos para insertar en base de datos. Si el trabajo ya expiró en el servidor, desaparecerán al actualizar.", + "refresh": "Actualizar", + "empty": "No hay importaciones pendientes para esta empresa.", + "checking": "Comprobando con el servidor…", + "total_rows": "Total filas", + "valid_rows": "Válidas", + "resume": "Reanudar", + "remove": "Quitar", + "profiles": { + "customs_brokers": "Agentes Aduanales", + "clients_providers": "Clientes y Proveedores", + "exchange_rates": "Tipos de cambio", + "pedimentos": "Pedimentos", + "material_classes": "Clases", + "vehicles": "Vehículos", + "drivers": "Conductores", + "trailers": "Trailers", + "transporters": "Transportistas", + "part_numbers": "Partes", + "boms": "BOMs", + "exportacion": "Exportación (operaciones)", + "imports": "Importación (operaciones)" + } + }, + "config_empty": "No hay configuraciones específicas para este módulo.", + "modal": { + "title_pending": "Validación de importación", + "title_success": "Importación exitosa", + "title_warning": "Importación con observaciones", + "desc_pending": "Revise el análisis preliminar antes de confirmar la carga de datos.", + "desc_done": "El proceso de importación ha finalizado.", + "total_rows": "Total filas", + "valid_rows": "Válidos", + "invalid_rows": "Inválidos", + "errors": "Errores", + "errors_heading": "Detalle de errores (para corregir en el CSV)", + "errors_badge": "{shown} de {total} error(es)", + "errors_truncated": "Para consultar el resto de errores, descargue el CSV.", + "errors_missing_detail": "Se detectaron {count} fila(s) con errores pero el detalle no está disponible. Asegúrese de que el servidor esté actualizado y vuelva a subir el archivo.", + "scan_ok_title": "Archivo validado correctamente", + "scan_ok_body": "Todos los registros parecen correctos y listos para importar.", + "scan_problems_title": "Se detectaron problemas en el archivo", + "scan_problems_body": "Corrija los datos indicados abajo en su CSV y vuelva a subir, o confirme para importar solo las filas válidas (las erróneas se omitirán).", + "inserted": "Insertados", + "updated": "Actualizados", + "rejected": "Rechazados", + "rejected_hint": "Revisa el detalle por línea en la tabla inferior.", + "ref_gaps_title": "Brechas de referencia (FK / catálogos)", + "ref_gaps_body": "Hay {n} brecha(s) crítica(s) de referencia. Revisa catálogos y el detalle de filas rechazadas antes de reintentar.", + "ref_state_title": "Estado de referencias", + "ref_state_ok": "Referencias listas para operar (sin brechas críticas reportadas).", + "ref_state_other": "Sin brechas numéricas; revisa el mensaje del servidor si aplica.", + "skipped_reasons_heading": "Resumen de motivos de rechazo", + "commit_errors_heading": "Detalle de errores", + "rows_badge": "{n} filas", + "importing_records": "Importando registros…", + "cancel_operation": "Cancelar", + "processing": "Procesando…", + "confirm_load": "Confirmar carga", + "close": "Cerrar", + "th_line": "Línea", + "th_column": "Columna", + "th_message": "Mensaje", + "th_solution": "Solución", + "th_reference": "Referencia", + "th_reason": "Motivo", + "download_csv": "Descargar CSV" + } + }, + "clients_providers": { + "type_client": "Cliente", + "type_provider": "Proveedor", + "type_both": "Ambos" + }, + "common": { + "col_actions": "Acciones", + "col_description": "Descripción", + "col_code": "Código", + "col_key": "Clave", + "col_type": "Tipo", + "col_name": "Nombre", + "col_status": "Status", + "col_patent": "Patente", + "col_id": "ID", + "col_desc_es": "Descripción Español", + "col_desc_en": "Descripción Inglés", + "col_desc_en_short": "Desc. Inglés", + "col_value": "Valor", + "col_um": "U.M.", + "col_rfc": "RFC", + "col_unit_weight": "Peso Unitario", + "col_country": "País", + "col_level": "Nivel", + "col_to_code": "Hacia código", + "col_from_code": "Desde código", + "col_fraction": "Fracción", + "col_date": "Fecha", + "col_conversion_factor": "Factor de conversión", + "col_complement": "Complemento", + "col_zip": "Código Postal", + "col_key_m3": "Clave M3", + "col_key_ame": "Clave AME", + "col_classification": "Clasificación", + "col_class": "Clase", + "col_city": "Ciudad", + "col_year": "Año", + "col_customs": "Aduana", + "col_umt": "U.M.T.", + "col_um_stock": "U.M. Exist", + "col_location": "Ubicación", + "col_transporter": "Transportista", + "col_document_type": "Tipo Documento", + "col_trailer_type": "Tipo de Trailer", + "col_phone": "Teléfono", + "col_system": "Sistema", + "col_tax_paid": "¿Se pagó el impuesto?", + "col_seal": "Sello", + "col_section": "Sección", + "col_photo_path": "Ruta Foto", + "col_rfc_tax": "RFC / TAX-ID", + "col_rfc_query": "RFC Consulta", + "col_regime": "Régimen", + "col_bonded_warehouse": "Recinto Fiscalizado", + "col_dest_port": "Puerto Destino", + "col_arrival_port": "Puerto arribo", + "col_mx_exit_port": "PtoSalMex", + "col_program": "Programa", + "col_priority": "Prioridad", + "col_plural": "Plural", + "col_plates": "Placas", + "col_person_in_charge": "Persona a cargo", + "col_pedimento": "Pedimento", + "col_country_desc": "País / Descripción", + "col_vu_operation": "Núm. Operación VU", + "col_ext_num": "Num Ext.", + "col_fax": "Número Fax", + "col_trailer_number": "Número de Trailer", + "col_pedimento_number": "Número de Pedimento", + "col_license_number": "Número de Licencia", + "col_note": "Nota", + "col_program_no": "No. Programa", + "col_part_no": "No. Parte", + "col_currency_name": "Nombre de Moneda", + "col_driver_name": "Nombre del Conductor", + "col_section_name": "Nombre de la Sección", + "col_full_name": "Nombre Completo", + "col_notice_no": "No. Aviso", + "col_local_currency": "Moneda Local", + "col_foreign_currency": "Moneda Extranjera", + "col_currency": "Moneda", + "col_month": "Mes", + "col_manifest": "Manifesto", + "col_localization": "Localización", + "col_line": "Línea", + "col_amount": "Importe", + "col_fraction_us": "Fracción US", + "col_signature": "Firma", + "col_modified_date": "Fecha Modificación", + "col_start_date": "Fecha Inicio", + "col_end_date": "Fecha Final", + "col_entry_date": "Fecha entrada", + "col_payment_date": "Fecha de Pago", + "col_created_date": "Fecha de Creación", + "col_email": "Email", + "col_edocument": "E-Document", + "col_address": "Dirección", + "col_cost": "Costo", + "col_container": "Contenedor", + "col_consecutive": "Consecutivo", + "col_concept": "Concepto", + "col_neighborhood": "Colonia", + "col_location_code": "Código Ubicación", + "col_regime_code": "Código Régimen", + "col_port_code": "Código Puerto", + "col_pedimento_code": "Código Pedimento", + "col_customs_code": "Código Aduanal", + "col_ace_code": "Código ACE", + "col_aamex_code": "Código AAMEX", + "col_a76_code": "Código A76 / SCAII", + "col_client": "Cliente", + "col_carrier_key": "Clave Transportista", + "col_key_mx": "Clave MEX", + "col_broker_key": "Clave Agente", + "col_street": "Calles", + "col_authorized": "Autorizado", + "col_file": "Archivo", + "col_last_name": "Apellido", + "col_e_receipt": "Acuse Electrónico", + "btn_refresh": "Actualizar", + "btn_new_record": "Nuevo Registro", + "sub_countries": "Gestiona los países disponibles en el sistema", + "new_country": "Nuevo País", + "sub_sectors": "Gestiona los sectores económicos del sistema", + "new_sector": "Nuevo Sector", + "sub_states": "Gestiona los estados y regiones del sistema aduanero", + "sub_currency_types": "Gestiona los tipos de moneda disponibles en el sistema", + "new_currency_type": "Nuevo Tipo de Moneda", + "sub_containers": "Catálogo de Tipos de Contenedores", + "sub_customs_sections": "Gestiona las secciones aduanales del sistema", + "new_section": "Nueva Sección", + "sub_customs_warehouses": "Gestiona los recintos fiscalizados aduanales en el sistema", + "new_warehouse": "Nuevo Recinto", + "sub_invoice_types": "Gestiona los tipos de factura en el sistema", + "new_type": "Nuevo Tipo", + "sub_material_types": "Gestiona los tipos de materiales disponibles en el sistema", + "sub_payment_methods": "Gestiona las formas de pago disponibles en el sistema", + "sub_pedimento_codes": "Gestiona las claves de pedimento del sistema aduanero", + "sub_pedimento_regimens": "Gestiona los regímenes de pedimento en el sistema", + "new_regime": "Nuevo Régimen", + "sub_transport_modes": "Gestiona los métodos de transporte disponibles en el sistema", + "new_transport_mode": "Nuevo Modo", + "sub_transport_types": "Gestiona los tipos de transporte del sistema aduanero", + "sub_valuation_methods": "Gestiona los métodos de valoración en el sistema", + "new_method": "Nuevo Método", + "sub_code_pedimento_regimens": "Relación entre códigos de pedimento y regímenes aduaneros", + "sub_document_types_digitization": "Consulta el catálogo fijo de solo lectura utilizado por digitalización y pedimentos.", + "sub_concepts": "Catálogo de Conceptos", + "sub_customs_broker_concepts": "Catálogo de Conceptos de Agente Aduanal", + "sub_classification_concepts": "Catálogo de Clasificaciones de Conceptos", + "sub_identifiers": "Catálogo de Identificadores del sistema", + "sub_legends": "Catálogo de Leyendas del sistema", + "sub_seals": "Catálogo de Sellos del sistema", + "sub_ports": "Catálogo de Puertos del sistema", + "sub_prevalidators": "Catálogo de prevalidadores", + "new_prevalidator": "Nuevo Prevalidador", + "sub_electronic_notices": "Catálogo de Avisos Electrónicos del sistema", + "sub_inpc": "Catálogo de INPC del sistema", + "sub_error_catalogs": "Catálogo de Catálogos de Errores del sistema", + "sub_packages": "Catálogo de Embalajes del sistema", + "sub_um_general": "Catálogo general de unidades de medida", + "sub_um_customs": "Catálogo de Unidades de Aduana del sistema", + "sub_um_american": "Catálogo de Unidades Americanas del sistema", + "sub_um_ace": "Catálogo de Unidades ACE del sistema", + "sub_um_oma": "Catálogo de Unidades OMA del sistema", + "new_unit": "Nueva Unidad", + "sub_unit_conversions": "Gestión del catálogo de conversiones de unidades entre sistemas", + "new_conversion": "Nueva Conversión", + "sub_exchange_rate": "Gestión del catálogo de tipos de cambio oficiales y personalizados", + "sub_signatures": "Catálogo de Firmas del sistema", + "sub_multi_currency": "Gestión del catálogo de tipos de moneda múltiple", + "sub_equivalencies": "Gestión del catálogo de equivalencias de unidades de medida", + "new_equivalence": "Nueva Equivalencia", + "sub_company_information": "Gestión de información de empresas", + "new_company": "Nueva Empresa", + "sub_transporters": "Gestión del catálogo de líneas transportistas", + "new_transporter": "Nuevo Transportista", + "sub_drivers": "Gestión del catálogo de conductores", + "new_driver": "Nuevo Conductor", + "sub_trailers": "Gestión del catálogo de trailers de la compañía", + "new_trailer": "Nuevo Trailer", + "sub_pedimentos": "Gestiona los pedimentos del sistema", + "new_pedimento": "Nuevo Pedimento", + "sub_brokers": "Administración de Agentes y Secciones Aduanales", + "sub_parts": "Gestiona y consulta las partes de inventario y activo fijo", + "new_part": "Nueva Parte", + "title_classes_inventory": "Clases de Inventario", + "title_classes_fa": "Clases de Activo Fijo", + "sub_classes_inventory": "Gestiona y consulta las clases del sistema de inventario", + "sub_classes_fa": "Gestiona y consulta las clases de activo fijo", + "sub_fda": "Gestión de códigos FDA para mercancías", + "new_fda_code": "Nuevo Código", + "btn_new_short": "Nuevo", + "title_users": "Gestión de Usuarios y Roles", + "sub_users": "Administra usuarios, roles y permisos de tu organización", + "title_roles": "Roles y Permisos", + "sub_roles": "Gestiona los roles de la compañía y sus permisos", + "title_settings": "Configuraciones Generales", + "sub_settings": "Configura los parámetros del sistema.", + "title_account": "Configuración de Cuenta", + "sub_account": "Gestiona tu información personal y preferencias", + "sub_expiration_report": "Reportes de Control Fiscal", + "maint_title": "En Construcción", + "maint_desc": "¡MÓDULO AÚN NO DISPONIBLE!", + "maint_back": "Volver al Inicio", + "sub_manifest": "Gestión de manifiestos de exportación", + "title_invoice_report": "Reporte de Facturas", + "title_help_library": "Biblioteca de Conocimiento", + "sub_help": "Manuales, Guías y Documentación del Sistema.", + "new_chapter": "Nuevo Capítulo", + "help_search_ph": "Buscar en la biblioteca...", + "help_empty": "La biblioteca está vacía.", + "help_format": "Formato", + "help_unknown": "Desconocido", + "help_size": "Tamaño", + "help_watch": "Ver", + "help_read": "Leer", + "help_open": "Abrir", + "help_delete_title": "¿Eliminar capítulo?", + "help_delete_desc": "Se eliminará permanentemente \"{title}\".", + "help_toast_load_error": "Error al cargar artículos", + "help_toast_deleted": "Capítulo eliminado", + "help_toast_error": "Error", + "help_back_library": "Volver a la Biblioteca", + "help_chapter_load_error": "Error al cargar el capítulo", + "help_pdf_document": "Documento PDF", + "help_open_tab": "Abrir en pestaña", + "help_download": "Descargar", + "help_video_unsupported": "Tu navegador no soporta videos.", + "help_file_info": "Información del archivo", + "help_file_download": "Archivo para descargar", + "help_no_preview": "Este archivo no tiene vista previa directa.", + "help_download_now": "Descargar ahora", + "help_in_chapter": "En este capítulo", + "btn_cancel": "Cancelar", + "btn_delete": "Eliminar", + "btn_edit": "Editar", + "btn_save": "Guardar", + "help_toast_article_load_error": "Error al cargar artículo", + "help_toast_chapter_created": "Capítulo creado", + "help_toast_changes_saved": "Cambios guardados", + "help_toast_save_error": "Error al guardar", + "help_toast_uploading_image": "Subiendo imagen...", + "help_toast_image_inserted": "Imagen insertada", + "help_toast_upload_error": "Error al subir", + "help_toast_uploading_file": "Subiendo {name}...", + "help_untitled": "Sin Título", + "help_toast_file_uploaded": "Archivo subido correctamente", + "help_toast_file_upload_error": "Error al subir archivo", + "help_back": "Volver", + "help_hide_preview": "Ocultar Vista Previa", + "help_show_preview": "Ver Vista Previa", + "help_config": "Configuración", + "help_field_title": "Título", + "help_ph_title": "Ej: Introducción", + "help_field_slug": "Identificador (Slug)", + "help_ph_slug": "ej-titulo-articulo", + "help_slug_hint": "Se autogenera del título si se deja vacío.", + "help_field_type": "Tipo de Contenido", + "help_type_article": "Artículo (Markdown)", + "help_type_document": "Otro Documento", + "help_advanced": "Opciones Avanzadas", + "help_field_category": "Categoría", + "help_ph_category": "Ej: General", + "help_field_order": "Orden", + "help_field_context": "Ruta Contextual", + "help_ph_context": "Ej: /dashboard/...", + "help_context_hint": "URL donde aparecerá este artículo.", + "help_field_tags": "Etiquetas", + "help_ph_tags": "Ej: facturas, mermas", + "help_bold": "Negrita", + "help_italic": "Cursiva", + "help_h1": "Título 1", + "help_h2": "Título 2", + "help_list": "Lista", + "help_link": "Enlace", + "help_upload_image": "Subir Imagen", + "help_ph_content": "# Empieza a escribir aquí...", + "help_drag_images": "Arrastra imágenes aquí", + "help_config_pdf": "Configuración de PDF", + "help_config_video": "Configuración de Video", + "help_config_document": "Configuración de Documento", + "help_upload_hint": "Sube el archivo que deseas asociar a este título.", + "help_file_loaded": "Archivo Cargado", + "help_view_file": "Ver Archivo", + "help_change_file": "Cambiar Archivo", + "help_select_file": "Selecciona un archivo", + "help_drag_drop": "O arrastra y suelta aquí" + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..2e49b7a --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,71 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "i18n:compile": "paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide", + "build": "vite build", + "preview": "vite preview", + "start": "node build/index.js", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "format": "prettier --write .", + "lint": "prettier --check . && eslint .", + "test:unit": "vitest --project server", + "test:unit:full": "vitest", + "test": "npm run test:unit -- --run && npm run test:e2e", + "test:e2e": "playwright test" + }, + "devDependencies": { + "@eslint/compat": "^1.4.0", + "@eslint/js": "^9.36.0", + "@inlang/paraglide-js": "^2.3.2", + "@internationalized/date": "^3.10.0", + "@lucide/svelte": "^0.561.0", + "@playwright/test": "^1.55.1", + "@sveltejs/adapter-node": "^5.3.2", + "@sveltejs/kit": "^2.43.2", + "@sveltejs/vite-plugin-svelte": "^6.2.0", + "@tailwindcss/forms": "^0.5.10", + "@tailwindcss/typography": "^0.5.19", + "@tailwindcss/vite": "^4.1.14", + "@tanstack/table-core": "^8.21.3", + "@types/node": "^20", + "@vitest/browser": "^3.2.4", + "bits-ui": "^2.14.4", + "clsx": "^2.1.1", + "eslint": "^9.36.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-svelte": "^3.12.4", + "globals": "^16.4.0", + "playwright": "^1.55.1", + "prettier": "^3.6.2", + "prettier-plugin-svelte": "^3.4.0", + "prettier-plugin-tailwindcss": "^0.7.1", + "svelte": "^5.39.5", + "svelte-check": "^4.3.2", + "tailwind-merge": "^3.3.1", + "tailwind-variants": "^3.1.1", + "tailwindcss": "^4.1.14", + "tw-animate-css": "^1.4.0", + "typescript": "^5.9.2", + "typescript-eslint": "^8.44.1", + "vite": "^7.1.7", + "vitest": "^3.2.4", + "vitest-browser-svelte": "^1.1.0" + }, + "dependencies": { + "@types/dompurify": "^3.2.0", + "@types/marked": "^6.0.0", + "chart.js": "^4.5.1", + "dompurify": "^3.0.9", + "keycloak-js": "^26.2.1", + "lucide-svelte": "^0.553.0", + "marked": "^12.0.0", + "svelte-sonner": "^1.0.7" + }, + "packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017" +} diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..05f0b0c --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,36 @@ +import { defineConfig } from '@playwright/test'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const authStatePath = path.join(__dirname, 'e2e/.auth/user.json'); +/** GitHub/Gitea/GitLab suelen exportar CI=true; Jenkins no siempre, pero define JENKINS_URL. */ +const inCI = process.env.CI === 'true' || Boolean(process.env.JENKINS_URL); + +export default defineConfig({ + // En CI, falla si queda un .only; en local no + forbidOnly: inCI, + timeout: 60_000, + use: { + baseURL: process.env.PLAYWRIGHT_TEST_BASE_URL || 'http://localhost:5173', + headless: inCI ? true : false + }, + workers: 1, + testDir: 'e2e', + projects: [ + { + name: 'setup', + testMatch: '**/auth.setup.ts', + use: { storageState: { cookies: [], origins: [] } } + }, + // Proyecto principal de E2E temporalmente desactivado mientras se ajusta el flujo + // de Workspace / Fixed Assets. Rehabilitar cuando los E2E estén listos. + // { + // name: 'tests', + // dependencies: ['setup'], + // testIgnore: ['**/auth.setup.ts', '**/demo.test.ts'], + // use: { storageState: authStatePath } + // } + ] +}); diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..aca2948 --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,3790 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@types/dompurify': + specifier: ^3.2.0 + version: 3.2.0 + '@types/marked': + specifier: ^6.0.0 + version: 6.0.0 + chart.js: + specifier: ^4.5.1 + version: 4.5.1 + dompurify: + specifier: ^3.0.9 + version: 3.3.1 + keycloak-js: + specifier: ^26.2.1 + version: 26.2.1 + lucide-svelte: + specifier: ^0.553.0 + version: 0.553.0(svelte@5.40.2) + marked: + specifier: ^12.0.0 + version: 12.0.2 + svelte-sonner: + specifier: ^1.0.7 + version: 1.0.7(svelte@5.40.2) + devDependencies: + '@eslint/compat': + specifier: ^1.4.0 + version: 1.4.0(eslint@9.38.0(jiti@2.6.1)) + '@eslint/js': + specifier: ^9.36.0 + version: 9.38.0 + '@inlang/paraglide-js': + specifier: ^2.3.2 + version: 2.4.0 + '@internationalized/date': + specifier: ^3.10.0 + version: 3.10.0 + '@lucide/svelte': + specifier: ^0.561.0 + version: 0.561.0(svelte@5.40.2) + '@playwright/test': + specifier: ^1.55.1 + version: 1.56.1 + '@sveltejs/adapter-node': + specifier: ^5.3.2 + version: 5.4.0(@sveltejs/kit@2.47.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1))) + '@sveltejs/kit': + specifier: ^2.43.2 + version: 2.47.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)) + '@sveltejs/vite-plugin-svelte': + specifier: ^6.2.0 + version: 6.2.1(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)) + '@tailwindcss/forms': + specifier: ^0.5.10 + version: 0.5.10(tailwindcss@4.1.14) + '@tailwindcss/typography': + specifier: ^0.5.19 + version: 0.5.19(tailwindcss@4.1.14) + '@tailwindcss/vite': + specifier: ^4.1.14 + version: 4.1.14(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)) + '@tanstack/table-core': + specifier: ^8.21.3 + version: 8.21.3 + '@types/node': + specifier: ^20 + version: 20.19.22 + '@vitest/browser': + specifier: ^3.2.4 + version: 3.2.4(playwright@1.56.1)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1))(vitest@3.2.4) + bits-ui: + specifier: ^2.14.4 + version: 2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.47.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2) + clsx: + specifier: ^2.1.1 + version: 2.1.1 + eslint: + specifier: ^9.36.0 + version: 9.38.0(jiti@2.6.1) + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@9.38.0(jiti@2.6.1)) + eslint-plugin-svelte: + specifier: ^3.12.4 + version: 3.12.4(eslint@9.38.0(jiti@2.6.1))(svelte@5.40.2) + globals: + specifier: ^16.4.0 + version: 16.4.0 + playwright: + specifier: ^1.55.1 + version: 1.56.1 + prettier: + specifier: ^3.6.2 + version: 3.6.2 + prettier-plugin-svelte: + specifier: ^3.4.0 + version: 3.4.0(prettier@3.6.2)(svelte@5.40.2) + prettier-plugin-tailwindcss: + specifier: ^0.7.1 + version: 0.7.1(prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.40.2))(prettier@3.6.2) + svelte: + specifier: ^5.39.5 + version: 5.40.2 + svelte-check: + specifier: ^4.3.2 + version: 4.3.3(picomatch@4.0.3)(svelte@5.40.2)(typescript@5.9.3) + tailwind-merge: + specifier: ^3.3.1 + version: 3.3.1 + tailwind-variants: + specifier: ^3.1.1 + version: 3.1.1(tailwind-merge@3.3.1)(tailwindcss@4.1.14) + tailwindcss: + specifier: ^4.1.14 + version: 4.1.14 + tw-animate-css: + specifier: ^1.4.0 + version: 1.4.0 + typescript: + specifier: ^5.9.2 + version: 5.9.3 + typescript-eslint: + specifier: ^8.44.1 + version: 8.46.1(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) + vite: + specifier: ^7.1.7 + version: 7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1) + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/node@20.19.22)(@vitest/browser@3.2.4)(jiti@2.6.1)(lightningcss@1.30.1) + vitest-browser-svelte: + specifier: ^1.1.0 + version: 1.1.0(@vitest/browser@3.2.4)(svelte@5.40.2)(vitest@3.2.4) + +packages: + + '@babel/code-frame@7.27.1': + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.27.1': + resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} + engines: {node: '>=6.9.0'} + + '@babel/runtime@7.28.4': + resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.25.11': + resolution: {integrity: sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.11': + resolution: {integrity: sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.11': + resolution: {integrity: sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.11': + resolution: {integrity: sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.11': + resolution: {integrity: sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.11': + resolution: {integrity: sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.11': + resolution: {integrity: sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.11': + resolution: {integrity: sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.11': + resolution: {integrity: sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.11': + resolution: {integrity: sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.11': + resolution: {integrity: sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.11': + resolution: {integrity: sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.11': + resolution: {integrity: sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.11': + resolution: {integrity: sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.11': + resolution: {integrity: sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.11': + resolution: {integrity: sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.11': + resolution: {integrity: sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.11': + resolution: {integrity: sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.11': + resolution: {integrity: sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.11': + resolution: {integrity: sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.11': + resolution: {integrity: sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.11': + resolution: {integrity: sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.11': + resolution: {integrity: sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.11': + resolution: {integrity: sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.11': + resolution: {integrity: sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.11': + resolution: {integrity: sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.0': + resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.1': + resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/compat@1.4.0': + resolution: {integrity: sha512-DEzm5dKeDBPm3r08Ixli/0cmxr8LkRdwxMRUIJBlSCpAwSrvFEJpVBzV+66JhDxiaqKxnRzCXhtiMiczF7Hglg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.40 || 9 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/config-array@0.21.1': + resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.1': + resolution: {integrity: sha512-csZAzkNhsgwb0I/UAV6/RGFTbiakPCf0ZrGmrIxQpYvGZ00PhTkSnyKNolphgIvmnJeGw6rcGVEXfTzUnFuEvw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.16.0': + resolution: {integrity: sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.1': + resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.38.0': + resolution: {integrity: sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.0': + resolution: {integrity: sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@floating-ui/core@1.7.3': + resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} + + '@floating-ui/dom@1.7.4': + resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==} + + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@inlang/paraglide-js@2.4.0': + resolution: {integrity: sha512-T/m9uoev574/1JrhCnPcgK1xnAwkVMgaDev4LFthnmID8ubX2xjboSGO3IztwXWwO0aJoT1UJr89JCwjbwgnJQ==} + hasBin: true + + '@inlang/recommend-sherlock@0.2.1': + resolution: {integrity: sha512-ckv8HvHy/iTqaVAEKrr+gnl+p3XFNwe5D2+6w6wJk2ORV2XkcRkKOJ/XsTUJbPSiyi4PI+p+T3bqbmNx/rDUlg==} + + '@inlang/sdk@2.4.9': + resolution: {integrity: sha512-cvz/C1rF5WBxzHbEoiBoI6Sz6q6M+TdxfWkEGBYTD77opY8i8WN01prUWXEM87GPF4SZcyIySez9U0Ccm12oFQ==} + engines: {node: '>=18.0.0'} + + '@internationalized/date@3.10.0': + resolution: {integrity: sha512-oxDR/NTEJ1k+UFVQElaNIk65E/Z83HK1z1WI3lQyhTtnNg4R5oVXaPzK3jcpKG8UHKDVuDQHzn+wsxSz8RP3aw==} + + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@kurkle/color@0.3.4': + resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} + + '@lix-js/sdk@0.4.7': + resolution: {integrity: sha512-pRbW+joG12L0ULfMiWYosIW0plmW4AsUdiPCp+Z8rAsElJ+wJ6in58zhD3UwUcd4BNcpldEGjg6PdA7e0RgsDQ==} + engines: {node: '>=18'} + + '@lix-js/server-protocol-schema@0.1.1': + resolution: {integrity: sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ==} + + '@lucide/svelte@0.561.0': + resolution: {integrity: sha512-vofKV2UFVrKE6I4ewKJ3dfCXSV6iP6nWVmiM83MLjsU91EeJcEg7LoWUABLp/aOTxj1HQNbJD1f3g3L0JQgH9A==} + peerDependencies: + svelte: ^5 + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@playwright/test@1.56.1': + resolution: {integrity: sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==} + engines: {node: '>=18'} + hasBin: true + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@rollup/plugin-commonjs@28.0.8': + resolution: {integrity: sha512-o1Ug9PxYsF61R7/NXO/GgMZZproLd/WH2XA53Tp9ppf6bU1lMlTtC/gUM6zM3mesi2E0rypk+PNtVrELREyWEQ==} + engines: {node: '>=16.0.0 || 14 >= 14.17'} + peerDependencies: + rollup: ^2.68.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-json@6.1.0': + resolution: {integrity: sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-node-resolve@16.0.3': + resolution: {integrity: sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.78.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/pluginutils@5.3.0': + resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/rollup-android-arm-eabi@4.52.4': + resolution: {integrity: sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.52.4': + resolution: {integrity: sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.52.4': + resolution: {integrity: sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.52.4': + resolution: {integrity: sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.52.4': + resolution: {integrity: sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.52.4': + resolution: {integrity: sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.52.4': + resolution: {integrity: sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.52.4': + resolution: {integrity: sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.52.4': + resolution: {integrity: sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.52.4': + resolution: {integrity: sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.52.4': + resolution: {integrity: sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-gnu@4.52.4': + resolution: {integrity: sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-gnu@4.52.4': + resolution: {integrity: sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.52.4': + resolution: {integrity: sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.52.4': + resolution: {integrity: sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.52.4': + resolution: {integrity: sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.52.4': + resolution: {integrity: sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openharmony-arm64@4.52.4': + resolution: {integrity: sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.52.4': + resolution: {integrity: sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.52.4': + resolution: {integrity: sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.52.4': + resolution: {integrity: sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.52.4': + resolution: {integrity: sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==} + cpu: [x64] + os: [win32] + + '@sinclair/typebox@0.31.28': + resolution: {integrity: sha512-/s55Jujywdw/Jpan+vsy6JZs1z2ZTGxTmbZTPiuSL2wz9mfzA2gN1zzaqmvfi4pq+uOt7Du85fkiwv5ymW84aQ==} + + '@sqlite.org/sqlite-wasm@3.48.0-build4': + resolution: {integrity: sha512-hI6twvUkzOmyGZhQMza1gpfqErZxXRw6JEsiVjUbo7tFanVD+8Oil0Ih3l2nGzHdxPI41zFmfUQG7GHqhciKZQ==} + hasBin: true + + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + + '@sveltejs/acorn-typescript@1.0.6': + resolution: {integrity: sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==} + peerDependencies: + acorn: ^8.9.0 + + '@sveltejs/adapter-node@5.4.0': + resolution: {integrity: sha512-NMsrwGVPEn+J73zH83Uhss/hYYZN6zT3u31R3IHAn3MiKC3h8fjmIAhLfTSOeNHr5wPYfjjMg8E+1gyFgyrEcQ==} + peerDependencies: + '@sveltejs/kit': ^2.4.0 + + '@sveltejs/kit@2.47.1': + resolution: {integrity: sha512-1v+MbMHxTi6ctQyxmz3owLKqZGaBHyx4EQqTdq/PvDswPFzw3WlqhrOKOh2ZzH23+XpQGEF9G+KDIgYJE+byvg==} + engines: {node: '>=18.13'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.0.0 + '@sveltejs/vite-plugin-svelte': ^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 + svelte: ^4.0.0 || ^5.0.0-next.0 + vite: ^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + + '@sveltejs/vite-plugin-svelte-inspector@5.0.1': + resolution: {integrity: sha512-ubWshlMk4bc8mkwWbg6vNvCeT7lGQojE3ijDh3QTR6Zr/R+GXxsGbyH4PExEPpiFmqPhYiVSVmHBjUcVc1JIrA==} + engines: {node: ^20.19 || ^22.12 || >=24} + peerDependencies: + '@sveltejs/vite-plugin-svelte': ^6.0.0-next.0 + svelte: ^5.0.0 + vite: ^6.3.0 || ^7.0.0 + + '@sveltejs/vite-plugin-svelte@6.2.1': + resolution: {integrity: sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==} + engines: {node: ^20.19 || ^22.12 || >=24} + peerDependencies: + svelte: ^5.0.0 + vite: ^6.3.0 || ^7.0.0 + + '@swc/helpers@0.5.17': + resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==} + + '@tailwindcss/forms@0.5.10': + resolution: {integrity: sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw==} + peerDependencies: + tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1' + + '@tailwindcss/node@4.1.14': + resolution: {integrity: sha512-hpz+8vFk3Ic2xssIA3e01R6jkmsAhvkQdXlEbRTk6S10xDAtiQiM3FyvZVGsucefq764euO/b8WUW9ysLdThHw==} + + '@tailwindcss/oxide-android-arm64@4.1.14': + resolution: {integrity: sha512-a94ifZrGwMvbdeAxWoSuGcIl6/DOP5cdxagid7xJv6bwFp3oebp7y2ImYsnZBMTwjn5Ev5xESvS3FFYUGgPODQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.1.14': + resolution: {integrity: sha512-HkFP/CqfSh09xCnrPJA7jud7hij5ahKyWomrC3oiO2U9i0UjP17o9pJbxUN0IJ471GTQQmzwhp0DEcpbp4MZTA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.1.14': + resolution: {integrity: sha512-eVNaWmCgdLf5iv6Qd3s7JI5SEFBFRtfm6W0mphJYXgvnDEAZ5sZzqmI06bK6xo0IErDHdTA5/t7d4eTfWbWOFw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.1.14': + resolution: {integrity: sha512-QWLoRXNikEuqtNb0dhQN6wsSVVjX6dmUFzuuiL09ZeXju25dsei2uIPl71y2Ic6QbNBsB4scwBoFnlBfabHkEw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.14': + resolution: {integrity: sha512-VB4gjQni9+F0VCASU+L8zSIyjrLLsy03sjcR3bM0V2g4SNamo0FakZFKyUQ96ZVwGK4CaJsc9zd/obQy74o0Fw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.14': + resolution: {integrity: sha512-qaEy0dIZ6d9vyLnmeg24yzA8XuEAD9WjpM5nIM1sUgQ/Zv7cVkharPDQcmm/t/TvXoKo/0knI3me3AGfdx6w1w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.1.14': + resolution: {integrity: sha512-ISZjT44s59O8xKsPEIesiIydMG/sCXoMBCqsphDm/WcbnuWLxxb+GcvSIIA5NjUw6F8Tex7s5/LM2yDy8RqYBQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.1.14': + resolution: {integrity: sha512-02c6JhLPJj10L2caH4U0zF8Hji4dOeahmuMl23stk0MU1wfd1OraE7rOloidSF8W5JTHkFdVo/O7uRUJJnUAJg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.1.14': + resolution: {integrity: sha512-TNGeLiN1XS66kQhxHG/7wMeQDOoL0S33x9BgmydbrWAb9Qw0KYdd8o1ifx4HOGDWhVmJ+Ul+JQ7lyknQFilO3Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.1.14': + resolution: {integrity: sha512-uZYAsaW/jS/IYkd6EWPJKW/NlPNSkWkBlaeVBi/WsFQNP05/bzkebUL8FH1pdsqx4f2fH/bWFcUABOM9nfiJkQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.14': + resolution: {integrity: sha512-Az0RnnkcvRqsuoLH2Z4n3JfAef0wElgzHD5Aky/e+0tBUxUhIeIqFBTMNQvmMRSP15fWwmvjBxZ3Q8RhsDnxAA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.1.14': + resolution: {integrity: sha512-ttblVGHgf68kEE4om1n/n44I0yGPkCPbLsqzjvybhpwa6mKKtgFfAzy6btc3HRmuW7nHe0OOrSeNP9sQmmH9XA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.1.14': + resolution: {integrity: sha512-23yx+VUbBwCg2x5XWdB8+1lkPajzLmALEfMb51zZUBYaYVPDQvBSD/WYDqiVyBIo2BZFa3yw1Rpy3G2Jp+K0dw==} + engines: {node: '>= 10'} + + '@tailwindcss/typography@0.5.19': + resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' + + '@tailwindcss/vite@4.1.14': + resolution: {integrity: sha512-BoFUoU0XqgCUS1UXWhmDJroKKhNXeDzD7/XwabjkDIAbMnc4ULn5e2FuEuBbhZ6ENZoSYzKlzvZ44Yr6EUDUSA==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 + + '@tanstack/table-core@8.21.3': + resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} + engines: {node: '>=12'} + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/chai@5.2.2': + resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} + + '@types/cookie@0.6.0': + resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/dompurify@3.2.0': + resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==} + deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed. + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/marked@6.0.0': + resolution: {integrity: sha512-jmjpa4BwUsmhxcfsgUit/7A9KbrC48Q0q8KvnY107ogcjGgTFDlIL3RpihNpx2Mu1hM4mdFQjoVc4O6JoGKHsA==} + deprecated: This is a stub types definition. marked provides its own type definitions, so you do not need this installed. + + '@types/node@20.19.22': + resolution: {integrity: sha512-hRnu+5qggKDSyWHlnmThnUqg62l29Aj/6vcYgUaSFL9oc7DVjeWEQN3PRgdSc6F8d9QRMWkf36CLMch1Do/+RQ==} + + '@types/resolve@1.20.2': + resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@typescript-eslint/eslint-plugin@8.46.1': + resolution: {integrity: sha512-rUsLh8PXmBjdiPY+Emjz9NX2yHvhS11v0SR6xNJkm5GM1MO9ea/1GoDKlHHZGrOJclL/cZ2i/vRUYVtjRhrHVQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.46.1 + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/parser@8.46.1': + resolution: {integrity: sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.46.1': + resolution: {integrity: sha512-FOIaFVMHzRskXr5J4Jp8lFVV0gz5ngv3RHmn+E4HYxSJ3DgDzU7fVI1/M7Ijh1zf6S7HIoaIOtln1H5y8V+9Zg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/scope-manager@8.46.1': + resolution: {integrity: sha512-weL9Gg3/5F0pVQKiF8eOXFZp8emqWzZsOJuWRUNtHT+UNV2xSJegmpCNQHy37aEQIbToTq7RHKhWvOsmbM680A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.46.1': + resolution: {integrity: sha512-X88+J/CwFvlJB+mK09VFqx5FE4H5cXD+H/Bdza2aEWkSb8hnWIQorNcscRl4IEo1Cz9VI/+/r/jnGWkbWPx54g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.46.1': + resolution: {integrity: sha512-+BlmiHIiqufBxkVnOtFwjah/vrkF4MtKKvpXrKSPLCkCtAp8H01/VV43sfqA98Od7nJpDcFnkwgyfQbOG0AMvw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/types@8.46.1': + resolution: {integrity: sha512-C+soprGBHwWBdkDpbaRC4paGBrkIXxVlNohadL5o0kfhsXqOC6GYH2S/Obmig+I0HTDl8wMaRySwrfrXVP8/pQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.46.1': + resolution: {integrity: sha512-uIifjT4s8cQKFQ8ZBXXyoUODtRoAd7F7+G8MKmtzj17+1UbdzFl52AzRyZRyKqPHhgzvXunnSckVu36flGy8cg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.46.1': + resolution: {integrity: sha512-vkYUy6LdZS7q1v/Gxb2Zs7zziuXN0wxqsetJdeZdRe/f5dwJFglmuvZBfTUivCtjH725C1jWCDfpadadD95EDQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/visitor-keys@8.46.1': + resolution: {integrity: sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitest/browser@3.2.4': + resolution: {integrity: sha512-tJxiPrWmzH8a+w9nLKlQMzAKX/7VjFs50MWgcAj7p9XQ7AQ9/35fByFYptgPELyLw+0aixTnC4pUWV+APcZ/kw==} + peerDependencies: + playwright: '*' + safaridriver: '*' + vitest: 3.2.4 + webdriverio: ^7.0.0 || ^8.0.0 || ^9.0.0 + peerDependenciesMeta: + playwright: + optional: true + safaridriver: + optional: true + webdriverio: + optional: true + + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + array-timsort@1.0.3: + resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + bits-ui@2.14.4: + resolution: {integrity: sha512-W6kenhnbd/YVvur+DKkaVJ6GldE53eLewur5AhUCqslYQ0vjZr8eWlOfwZnMiPB+PF5HMVqf61vXBvmyrAmPWg==} + engines: {node: '>=20'} + peerDependencies: + '@internationalized/date': ^3.8.1 + svelte: ^5.33.0 + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chart.js@4.5.1: + resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==} + engines: {pnpm: '>=8'} + + check-error@2.1.1: + resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} + engines: {node: '>= 16'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + + comment-json@4.4.1: + resolution: {integrity: sha512-r1To31BQD5060QdkC+Iheai7gHwoSZobzunqkf2/kQ6xIAfJyrKNAFUwdKvkK7Qgu7pVTKQEa7ok7Ed3ycAJgg==} + engines: {node: '>= 6'} + + commondir@1.0.1: + resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + consola@3.4.0: + resolution: {integrity: sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==} + engines: {node: ^14.18.0 || >=16.10.0} + + cookie@0.6.0: + resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} + engines: {node: '>= 0.6'} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + dedent@1.5.1: + resolution: {integrity: sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devalue@5.4.1: + resolution: {integrity: sha512-YtoaOfsqjbZQKGIMRYDWKjUmSB4VJ/RElB+bXZawQAQYAo4xu08GKTMVlsZDTF6R2MbAgjcAQRPI5eIyRAT2OQ==} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dompurify@3.3.1: + resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==} + + enhanced-resolve@5.18.3: + resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} + engines: {node: '>=10.13.0'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.25.11: + resolution: {integrity: sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==} + engines: {node: '>=18'} + hasBin: true + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-plugin-svelte@3.12.4: + resolution: {integrity: sha512-hD7wPe+vrPgx3U2X2b/wyTMtWobm660PygMGKrWWYTc9lvtY8DpNFDaU2CJQn1szLjGbn/aJ3g8WiXuKakrEkw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.1 || ^9.0.0 + svelte: ^3.37.0 || ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + svelte: + optional: true + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint@9.38.0: + resolution: {integrity: sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esquery@1.6.0: + resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + engines: {node: '>=0.10'} + + esrap@2.1.0: + resolution: {integrity: sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA==} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + expect-type@1.2.2: + resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} + engines: {node: '>=12.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.19.1: + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@16.4.0: + resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==} + engines: {node: '>=18'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + human-id@4.1.2: + resolution: {integrity: sha512-v/J+4Z/1eIJovEBdlV5TYj1IR+ZiohcYGRY+qN/oC9dAfKzVT023N/Bgw37hrKCoVRBvk3bqyzpr2PP5YeTMSg==} + hasBin: true + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inline-style-parser@0.2.6: + resolution: {integrity: sha512-gtGXVaBdl5mAes3rPcMedEBm12ibjt1kDMFfheul1wUAOVEJW60voNdMVzVkfLN06O7ZaD/rxhfKgtlgtTbMjg==} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-module@1.0.0: + resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-reference@1.2.1: + resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} + + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + js-sha256@0.11.1: + resolution: {integrity: sha512-o6WSo/LUvY2uC4j7mO50a2ms7E/EAdbP0swigLV+nzHKTTaYnaLIWJ02VdXrsJX0vGedDESQnLsOekr94ryfjg==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + keycloak-js@26.2.1: + resolution: {integrity: sha512-bZt6fQj/TLBAmivXSxSlqAJxBx/knNZDQGJIW4ensGYGN4N6tUKV8Zj3Y7/LOV8eIpvWsvqV70fbACihK8Ze0Q==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + known-css-properties@0.37.0: + resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==} + + kysely@0.27.6: + resolution: {integrity: sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==} + engines: {node: '>=14.0.0'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-darwin-arm64@1.30.1: + resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.30.1: + resolution: {integrity: sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.30.1: + resolution: {integrity: sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.30.1: + resolution: {integrity: sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.30.1: + resolution: {integrity: sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.30.1: + resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.30.1: + resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.30.1: + resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.30.1: + resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.30.1: + resolution: {integrity: sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.30.1: + resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==} + engines: {node: '>= 12.0.0'} + + lilconfig@2.1.0: + resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} + engines: {node: '>=10'} + + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lucide-svelte@0.553.0: + resolution: {integrity: sha512-pOqzFX+RfcNyvjF0+nGVnSmprd+4NQ6mvpLOLEmhTyZGOad8+OtCl65822E7Rx9qE7rfKw84ODKI2v318JZ/7g==} + peerDependencies: + svelte: ^3 || ^4 || ^5.0.0-next.42 + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.19: + resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} + + marked@12.0.2: + resolution: {integrity: sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==} + engines: {node: '>= 18'} + hasBin: true + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mini-svg-data-uri@1.4.4: + resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==} + hasBin: true + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + playwright-core@1.56.1: + resolution: {integrity: sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.56.1: + resolution: {integrity: sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==} + engines: {node: '>=18'} + hasBin: true + + postcss-load-config@3.1.4: + resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==} + engines: {node: '>= 10'} + peerDependencies: + postcss: '>=8.0.9' + ts-node: '>=9.0.0' + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + + postcss-safe-parser@7.0.1: + resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==} + engines: {node: '>=18.0'} + peerDependencies: + postcss: ^8.4.31 + + postcss-scss@4.0.9: + resolution: {integrity: sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.4.29 + + postcss-selector-parser@6.0.10: + resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} + engines: {node: '>=4'} + + postcss-selector-parser@7.1.0: + resolution: {integrity: sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==} + engines: {node: '>=4'} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-plugin-svelte@3.4.0: + resolution: {integrity: sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==} + peerDependencies: + prettier: ^3.0.0 + svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0 + + prettier-plugin-tailwindcss@0.7.1: + resolution: {integrity: sha512-Bzv1LZcuiR1Sk02iJTS1QzlFNp/o5l2p3xkopwOrbPmtMeh3fK9rVW5M3neBQzHq+kGKj/4LGQMTNcTH4NGPtQ==} + engines: {node: '>=20.19'} + peerDependencies: + '@ianvs/prettier-plugin-sort-imports': '*' + '@prettier/plugin-hermes': '*' + '@prettier/plugin-oxc': '*' + '@prettier/plugin-pug': '*' + '@shopify/prettier-plugin-liquid': '*' + '@trivago/prettier-plugin-sort-imports': '*' + '@zackad/prettier-plugin-twig': '*' + prettier: ^3.0 + prettier-plugin-astro: '*' + prettier-plugin-css-order: '*' + prettier-plugin-jsdoc: '*' + prettier-plugin-marko: '*' + prettier-plugin-multiline-arrays: '*' + prettier-plugin-organize-attributes: '*' + prettier-plugin-organize-imports: '*' + prettier-plugin-sort-imports: '*' + prettier-plugin-svelte: '*' + peerDependenciesMeta: + '@ianvs/prettier-plugin-sort-imports': + optional: true + '@prettier/plugin-hermes': + optional: true + '@prettier/plugin-oxc': + optional: true + '@prettier/plugin-pug': + optional: true + '@shopify/prettier-plugin-liquid': + optional: true + '@trivago/prettier-plugin-sort-imports': + optional: true + '@zackad/prettier-plugin-twig': + optional: true + prettier-plugin-astro: + optional: true + prettier-plugin-css-order: + optional: true + prettier-plugin-jsdoc: + optional: true + prettier-plugin-marko: + optional: true + prettier-plugin-multiline-arrays: + optional: true + prettier-plugin-organize-attributes: + optional: true + prettier-plugin-organize-imports: + optional: true + prettier-plugin-sort-imports: + optional: true + prettier-plugin-svelte: + optional: true + + prettier@3.6.2: + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} + engines: {node: '>=14'} + hasBin: true + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve@1.22.10: + resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rollup@4.52.4: + resolution: {integrity: sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + runed@0.28.0: + resolution: {integrity: sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ==} + peerDependencies: + svelte: ^5.7.0 + + runed@0.35.1: + resolution: {integrity: sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==} + peerDependencies: + '@sveltejs/kit': ^2.21.0 + svelte: ^5.7.0 + peerDependenciesMeta: + '@sveltejs/kit': + optional: true + + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@2.7.1: + resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + sqlite-wasm-kysely@0.3.0: + resolution: {integrity: sha512-TzjBNv7KwRw6E3pdKdlRyZiTmUIE0UttT/Sl56MVwVARl/u5gp978KepazCJZewFUnlWHz9i3NQd4kOtP/Afdg==} + peerDependencies: + kysely: '*' + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + style-to-object@1.0.12: + resolution: {integrity: sha512-ddJqYnoT4t97QvN2C95bCgt+m7AAgXjVnkk/jxAfmp7EAB8nnqqZYEbMd3em7/vEomDb2LAQKAy1RFfv41mdNw==} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + svelte-check@4.3.3: + resolution: {integrity: sha512-RYP0bEwenDXzfv0P1sKAwjZSlaRyqBn0Fz1TVni58lqyEiqgwztTpmodJrGzP6ZT2aHl4MbTvWP6gbmQ3FOnBg==} + engines: {node: '>= 18.0.0'} + hasBin: true + peerDependencies: + svelte: ^4.0.0 || ^5.0.0-next.0 + typescript: '>=5.0.0' + + svelte-eslint-parser@1.3.3: + resolution: {integrity: sha512-oTrDR8Z7Wnguut7QH3YKh7JR19xv1seB/bz4dxU5J/86eJtZOU4eh0/jZq4dy6tAlz/KROxnkRQspv5ZEt7t+Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + svelte: ^3.37.0 || ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + svelte: + optional: true + + svelte-sonner@1.0.7: + resolution: {integrity: sha512-1EUFYmd7q/xfs2qCHwJzGPh9n5VJ3X6QjBN10fof2vxgy8fYE7kVfZ7uGnd7i6fQaWIr5KvXcwYXE/cmTEjk5A==} + peerDependencies: + svelte: ^5.0.0 + + svelte-toolbelt@0.10.6: + resolution: {integrity: sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==} + engines: {node: '>=18', pnpm: '>=8.7.0'} + peerDependencies: + svelte: ^5.30.2 + + svelte@5.40.2: + resolution: {integrity: sha512-wr/SwBVCVfeHU8FZr48vRrzSpWdBBzGo5mlErjGzeW4reJhK/CWutLZbk/eHwhKqO17ccjeTcvsqjrT4aK3wZA==} + engines: {node: '>=18'} + + tabbable@6.3.0: + resolution: {integrity: sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==} + + tailwind-merge@3.3.1: + resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==} + + tailwind-variants@3.1.1: + resolution: {integrity: sha512-ftLXe3krnqkMHsuBTEmaVUXYovXtPyTK7ckEfDRXS8PBZx0bAUas+A0jYxuKA5b8qg++wvQ3d2MQ7l/xeZxbZQ==} + engines: {node: '>=16.x', pnpm: '>=7.x'} + peerDependencies: + tailwind-merge: '>=3.0.0' + tailwindcss: '*' + peerDependenciesMeta: + tailwind-merge: + optional: true + + tailwindcss@4.1.14: + resolution: {integrity: sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA==} + + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + engines: {node: '>=6'} + + tar@7.5.1: + resolution: {integrity: sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==} + engines: {node: '>=18'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + ts-api-utils@2.1.0: + resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tw-animate-css@1.4.0: + resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.46.1: + resolution: {integrity: sha512-VHgijW803JafdSsDO8I761r3SHrgk4T00IdyQ+/UsthtgPRsBWQLqoSxOolxTpxRKi1kGXK0bSz4CoAc9ObqJA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unplugin@2.3.10: + resolution: {integrity: sha512-6NCPkv1ClwH+/BGE9QeoTIl09nuiAt0gS28nn1PvYXsGKRwM2TCbFA2QiilmehPDTXIe684k4rZI1yl3A1PCUw==} + engines: {node: '>=18.12.0'} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + urlpattern-polyfill@10.1.0: + resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + hasBin: true + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.1.10: + resolution: {integrity: sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.1.1: + resolution: {integrity: sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0 + peerDependenciesMeta: + vite: + optional: true + + vitest-browser-svelte@1.1.0: + resolution: {integrity: sha512-o98mCzKkWBjvmaGzi69rvyBd1IJ7zFPGI0jcID9vI4F5DmdG//YxkIbeQ7TS27hAVR+MULnBZNja2DUiuUBZyA==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + '@vitest/browser': ^2.1.0 || ^3.0.0 || ^4.0.0-0 + svelte: ^3 || ^4 || ^5 || ^5.0.0-next.0 + vitest: ^2.1.0 || ^3.0.0 || ^4.0.0-0 + + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + + yaml@1.10.2: + resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} + engines: {node: '>= 6'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zimmerframe@1.1.4: + resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} + +snapshots: + + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.27.1 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.27.1': {} + + '@babel/runtime@7.28.4': {} + + '@esbuild/aix-ppc64@0.25.11': + optional: true + + '@esbuild/android-arm64@0.25.11': + optional: true + + '@esbuild/android-arm@0.25.11': + optional: true + + '@esbuild/android-x64@0.25.11': + optional: true + + '@esbuild/darwin-arm64@0.25.11': + optional: true + + '@esbuild/darwin-x64@0.25.11': + optional: true + + '@esbuild/freebsd-arm64@0.25.11': + optional: true + + '@esbuild/freebsd-x64@0.25.11': + optional: true + + '@esbuild/linux-arm64@0.25.11': + optional: true + + '@esbuild/linux-arm@0.25.11': + optional: true + + '@esbuild/linux-ia32@0.25.11': + optional: true + + '@esbuild/linux-loong64@0.25.11': + optional: true + + '@esbuild/linux-mips64el@0.25.11': + optional: true + + '@esbuild/linux-ppc64@0.25.11': + optional: true + + '@esbuild/linux-riscv64@0.25.11': + optional: true + + '@esbuild/linux-s390x@0.25.11': + optional: true + + '@esbuild/linux-x64@0.25.11': + optional: true + + '@esbuild/netbsd-arm64@0.25.11': + optional: true + + '@esbuild/netbsd-x64@0.25.11': + optional: true + + '@esbuild/openbsd-arm64@0.25.11': + optional: true + + '@esbuild/openbsd-x64@0.25.11': + optional: true + + '@esbuild/openharmony-arm64@0.25.11': + optional: true + + '@esbuild/sunos-x64@0.25.11': + optional: true + + '@esbuild/win32-arm64@0.25.11': + optional: true + + '@esbuild/win32-ia32@0.25.11': + optional: true + + '@esbuild/win32-x64@0.25.11': + optional: true + + '@eslint-community/eslint-utils@4.9.0(eslint@9.38.0(jiti@2.6.1))': + dependencies: + eslint: 9.38.0(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.1': {} + + '@eslint/compat@1.4.0(eslint@9.38.0(jiti@2.6.1))': + dependencies: + '@eslint/core': 0.16.0 + optionalDependencies: + eslint: 9.38.0(jiti@2.6.1) + + '@eslint/config-array@0.21.1': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.1': + dependencies: + '@eslint/core': 0.16.0 + + '@eslint/core@0.16.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.1': + dependencies: + ajv: 6.12.6 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.0 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.38.0': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.0': + dependencies: + '@eslint/core': 0.16.0 + levn: 0.4.1 + + '@floating-ui/core@1.7.3': + dependencies: + '@floating-ui/utils': 0.2.10 + + '@floating-ui/dom@1.7.4': + dependencies: + '@floating-ui/core': 1.7.3 + '@floating-ui/utils': 0.2.10 + + '@floating-ui/utils@0.2.10': {} + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@inlang/paraglide-js@2.4.0': + dependencies: + '@inlang/recommend-sherlock': 0.2.1 + '@inlang/sdk': 2.4.9 + commander: 11.1.0 + consola: 3.4.0 + json5: 2.2.3 + unplugin: 2.3.10 + urlpattern-polyfill: 10.1.0 + transitivePeerDependencies: + - babel-plugin-macros + + '@inlang/recommend-sherlock@0.2.1': + dependencies: + comment-json: 4.4.1 + + '@inlang/sdk@2.4.9': + dependencies: + '@lix-js/sdk': 0.4.7 + '@sinclair/typebox': 0.31.28 + kysely: 0.27.6 + sqlite-wasm-kysely: 0.3.0(kysely@0.27.6) + uuid: 10.0.0 + transitivePeerDependencies: + - babel-plugin-macros + + '@internationalized/date@3.10.0': + dependencies: + '@swc/helpers': 0.5.17 + + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.2 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@kurkle/color@0.3.4': {} + + '@lix-js/sdk@0.4.7': + dependencies: + '@lix-js/server-protocol-schema': 0.1.1 + dedent: 1.5.1 + human-id: 4.1.2 + js-sha256: 0.11.1 + kysely: 0.27.6 + sqlite-wasm-kysely: 0.3.0(kysely@0.27.6) + uuid: 10.0.0 + transitivePeerDependencies: + - babel-plugin-macros + + '@lix-js/server-protocol-schema@0.1.1': {} + + '@lucide/svelte@0.561.0(svelte@5.40.2)': + dependencies: + svelte: 5.40.2 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.19.1 + + '@playwright/test@1.56.1': + dependencies: + playwright: 1.56.1 + + '@polka/url@1.0.0-next.29': {} + + '@rollup/plugin-commonjs@28.0.8(rollup@4.52.4)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.52.4) + commondir: 1.0.1 + estree-walker: 2.0.2 + fdir: 6.5.0(picomatch@4.0.3) + is-reference: 1.2.1 + magic-string: 0.30.19 + picomatch: 4.0.3 + optionalDependencies: + rollup: 4.52.4 + + '@rollup/plugin-json@6.1.0(rollup@4.52.4)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.52.4) + optionalDependencies: + rollup: 4.52.4 + + '@rollup/plugin-node-resolve@16.0.3(rollup@4.52.4)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.52.4) + '@types/resolve': 1.20.2 + deepmerge: 4.3.1 + is-module: 1.0.0 + resolve: 1.22.10 + optionalDependencies: + rollup: 4.52.4 + + '@rollup/pluginutils@5.3.0(rollup@4.52.4)': + dependencies: + '@types/estree': 1.0.8 + estree-walker: 2.0.2 + picomatch: 4.0.3 + optionalDependencies: + rollup: 4.52.4 + + '@rollup/rollup-android-arm-eabi@4.52.4': + optional: true + + '@rollup/rollup-android-arm64@4.52.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.52.4': + optional: true + + '@rollup/rollup-darwin-x64@4.52.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.52.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.52.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.52.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.52.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.52.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.52.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.52.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.52.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.52.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.52.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.52.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.52.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.52.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.52.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.52.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.52.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.52.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.52.4': + optional: true + + '@sinclair/typebox@0.31.28': {} + + '@sqlite.org/sqlite-wasm@3.48.0-build4': {} + + '@standard-schema/spec@1.0.0': {} + + '@sveltejs/acorn-typescript@1.0.6(acorn@8.15.0)': + dependencies: + acorn: 8.15.0 + + '@sveltejs/adapter-node@5.4.0(@sveltejs/kit@2.47.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))': + dependencies: + '@rollup/plugin-commonjs': 28.0.8(rollup@4.52.4) + '@rollup/plugin-json': 6.1.0(rollup@4.52.4) + '@rollup/plugin-node-resolve': 16.0.3(rollup@4.52.4) + '@sveltejs/kit': 2.47.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)) + rollup: 4.52.4 + + '@sveltejs/kit@2.47.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1))': + dependencies: + '@standard-schema/spec': 1.0.0 + '@sveltejs/acorn-typescript': 1.0.6(acorn@8.15.0) + '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)) + '@types/cookie': 0.6.0 + acorn: 8.15.0 + cookie: 0.6.0 + devalue: 5.4.1 + esm-env: 1.2.2 + kleur: 4.1.5 + magic-string: 0.30.19 + mrmime: 2.0.1 + sade: 1.8.1 + set-cookie-parser: 2.7.1 + sirv: 3.0.2 + svelte: 5.40.2 + vite: 7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1) + + '@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1))': + dependencies: + '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)) + debug: 4.4.3 + svelte: 5.40.2 + vite: 7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1) + transitivePeerDependencies: + - supports-color + + '@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1))': + dependencies: + '@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)) + debug: 4.4.3 + deepmerge: 4.3.1 + magic-string: 0.30.19 + svelte: 5.40.2 + vite: 7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1) + vitefu: 1.1.1(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)) + transitivePeerDependencies: + - supports-color + + '@swc/helpers@0.5.17': + dependencies: + tslib: 2.8.1 + + '@tailwindcss/forms@0.5.10(tailwindcss@4.1.14)': + dependencies: + mini-svg-data-uri: 1.4.4 + tailwindcss: 4.1.14 + + '@tailwindcss/node@4.1.14': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.18.3 + jiti: 2.6.1 + lightningcss: 1.30.1 + magic-string: 0.30.19 + source-map-js: 1.2.1 + tailwindcss: 4.1.14 + + '@tailwindcss/oxide-android-arm64@4.1.14': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.1.14': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.1.14': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.1.14': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.14': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.14': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.1.14': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.1.14': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.1.14': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.1.14': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.14': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.1.14': + optional: true + + '@tailwindcss/oxide@4.1.14': + dependencies: + detect-libc: 2.1.2 + tar: 7.5.1 + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.1.14 + '@tailwindcss/oxide-darwin-arm64': 4.1.14 + '@tailwindcss/oxide-darwin-x64': 4.1.14 + '@tailwindcss/oxide-freebsd-x64': 4.1.14 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.14 + '@tailwindcss/oxide-linux-arm64-gnu': 4.1.14 + '@tailwindcss/oxide-linux-arm64-musl': 4.1.14 + '@tailwindcss/oxide-linux-x64-gnu': 4.1.14 + '@tailwindcss/oxide-linux-x64-musl': 4.1.14 + '@tailwindcss/oxide-wasm32-wasi': 4.1.14 + '@tailwindcss/oxide-win32-arm64-msvc': 4.1.14 + '@tailwindcss/oxide-win32-x64-msvc': 4.1.14 + + '@tailwindcss/typography@0.5.19(tailwindcss@4.1.14)': + dependencies: + postcss-selector-parser: 6.0.10 + tailwindcss: 4.1.14 + + '@tailwindcss/vite@4.1.14(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1))': + dependencies: + '@tailwindcss/node': 4.1.14 + '@tailwindcss/oxide': 4.1.14 + tailwindcss: 4.1.14 + vite: 7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1) + + '@tanstack/table-core@8.21.3': {} + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/runtime': 7.28.4 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + + '@types/aria-query@5.0.4': {} + + '@types/chai@5.2.2': + dependencies: + '@types/deep-eql': 4.0.2 + + '@types/cookie@0.6.0': {} + + '@types/deep-eql@4.0.2': {} + + '@types/dompurify@3.2.0': + dependencies: + dompurify: 3.3.1 + + '@types/estree@1.0.8': {} + + '@types/json-schema@7.0.15': {} + + '@types/marked@6.0.0': + dependencies: + marked: 12.0.2 + + '@types/node@20.19.22': + dependencies: + undici-types: 6.21.0 + + '@types/resolve@1.20.2': {} + + '@types/trusted-types@2.0.7': + optional: true + + '@typescript-eslint/eslint-plugin@8.46.1(@typescript-eslint/parser@8.46.1(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.1 + '@typescript-eslint/parser': 8.46.1(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.46.1 + '@typescript-eslint/type-utils': 8.46.1(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.1(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.46.1 + eslint: 9.38.0(jiti@2.6.1) + graphemer: 1.4.0 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.1.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.46.1(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.46.1 + '@typescript-eslint/types': 8.46.1 + '@typescript-eslint/typescript-estree': 8.46.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.46.1 + debug: 4.4.3 + eslint: 9.38.0(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.46.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.46.1(typescript@5.9.3) + '@typescript-eslint/types': 8.46.1 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.46.1': + dependencies: + '@typescript-eslint/types': 8.46.1 + '@typescript-eslint/visitor-keys': 8.46.1 + + '@typescript-eslint/tsconfig-utils@8.46.1(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.46.1(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.46.1 + '@typescript-eslint/typescript-estree': 8.46.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.1(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.38.0(jiti@2.6.1) + ts-api-utils: 2.1.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.46.1': {} + + '@typescript-eslint/typescript-estree@8.46.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.46.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.46.1(typescript@5.9.3) + '@typescript-eslint/types': 8.46.1 + '@typescript-eslint/visitor-keys': 8.46.1 + debug: 4.4.3 + fast-glob: 3.3.3 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.7.3 + ts-api-utils: 2.1.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.46.1(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@9.38.0(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.46.1 + '@typescript-eslint/types': 8.46.1 + '@typescript-eslint/typescript-estree': 8.46.1(typescript@5.9.3) + eslint: 9.38.0(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.46.1': + dependencies: + '@typescript-eslint/types': 8.46.1 + eslint-visitor-keys: 4.2.1 + + '@vitest/browser@3.2.4(playwright@1.56.1)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1))(vitest@3.2.4)': + dependencies: + '@testing-library/dom': 10.4.1 + '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) + '@vitest/mocker': 3.2.4(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)) + '@vitest/utils': 3.2.4 + magic-string: 0.30.19 + sirv: 3.0.2 + tinyrainbow: 2.0.0 + vitest: 3.2.4(@types/node@20.19.22)(@vitest/browser@3.2.4)(jiti@2.6.1)(lightningcss@1.30.1) + ws: 8.18.3 + optionalDependencies: + playwright: 1.56.1 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.2 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.19 + optionalDependencies: + vite: 7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1) + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.19 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + acorn-jsx@5.3.2(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + argparse@2.0.1: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + array-timsort@1.0.3: {} + + assertion-error@2.0.1: {} + + axobject-query@4.1.0: {} + + balanced-match@1.0.2: {} + + bits-ui@2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.47.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2): + dependencies: + '@floating-ui/core': 1.7.3 + '@floating-ui/dom': 1.7.4 + '@internationalized/date': 3.10.0 + esm-env: 1.2.2 + runed: 0.35.1(@sveltejs/kit@2.47.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2) + svelte: 5.40.2 + svelte-toolbelt: 0.10.6(@sveltejs/kit@2.47.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2) + tabbable: 6.3.0 + transitivePeerDependencies: + - '@sveltejs/kit' + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + cac@6.7.14: {} + + callsites@3.1.0: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.1 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chart.js@4.5.1: + dependencies: + '@kurkle/color': 0.3.4 + + check-error@2.1.1: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + chownr@3.0.0: {} + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@11.1.0: {} + + comment-json@4.4.1: + dependencies: + array-timsort: 1.0.3 + core-util-is: 1.0.3 + esprima: 4.0.1 + + commondir@1.0.1: {} + + concat-map@0.0.1: {} + + consola@3.4.0: {} + + cookie@0.6.0: {} + + core-util-is@1.0.3: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + cssesc@3.0.0: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + dedent@1.5.1: {} + + deep-eql@5.0.2: {} + + deep-is@0.1.4: {} + + deepmerge@4.3.1: {} + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + devalue@5.4.1: {} + + dom-accessibility-api@0.5.16: {} + + dompurify@3.3.1: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + enhanced-resolve@5.18.3: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + + es-module-lexer@1.7.0: {} + + esbuild@0.25.11: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.11 + '@esbuild/android-arm': 0.25.11 + '@esbuild/android-arm64': 0.25.11 + '@esbuild/android-x64': 0.25.11 + '@esbuild/darwin-arm64': 0.25.11 + '@esbuild/darwin-x64': 0.25.11 + '@esbuild/freebsd-arm64': 0.25.11 + '@esbuild/freebsd-x64': 0.25.11 + '@esbuild/linux-arm': 0.25.11 + '@esbuild/linux-arm64': 0.25.11 + '@esbuild/linux-ia32': 0.25.11 + '@esbuild/linux-loong64': 0.25.11 + '@esbuild/linux-mips64el': 0.25.11 + '@esbuild/linux-ppc64': 0.25.11 + '@esbuild/linux-riscv64': 0.25.11 + '@esbuild/linux-s390x': 0.25.11 + '@esbuild/linux-x64': 0.25.11 + '@esbuild/netbsd-arm64': 0.25.11 + '@esbuild/netbsd-x64': 0.25.11 + '@esbuild/openbsd-arm64': 0.25.11 + '@esbuild/openbsd-x64': 0.25.11 + '@esbuild/openharmony-arm64': 0.25.11 + '@esbuild/sunos-x64': 0.25.11 + '@esbuild/win32-arm64': 0.25.11 + '@esbuild/win32-ia32': 0.25.11 + '@esbuild/win32-x64': 0.25.11 + + escape-string-regexp@4.0.0: {} + + eslint-config-prettier@10.1.8(eslint@9.38.0(jiti@2.6.1)): + dependencies: + eslint: 9.38.0(jiti@2.6.1) + + eslint-plugin-svelte@3.12.4(eslint@9.38.0(jiti@2.6.1))(svelte@5.40.2): + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@9.38.0(jiti@2.6.1)) + '@jridgewell/sourcemap-codec': 1.5.5 + eslint: 9.38.0(jiti@2.6.1) + esutils: 2.0.3 + globals: 16.4.0 + known-css-properties: 0.37.0 + postcss: 8.5.6 + postcss-load-config: 3.1.4(postcss@8.5.6) + postcss-safe-parser: 7.0.1(postcss@8.5.6) + semver: 7.7.3 + svelte-eslint-parser: 1.3.3(svelte@5.40.2) + optionalDependencies: + svelte: 5.40.2 + transitivePeerDependencies: + - ts-node + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint@9.38.0(jiti@2.6.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@9.38.0(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.1 + '@eslint/config-array': 0.21.1 + '@eslint/config-helpers': 0.4.1 + '@eslint/core': 0.16.0 + '@eslint/eslintrc': 3.3.1 + '@eslint/js': 9.38.0 + '@eslint/plugin-kit': 0.4.0 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.6.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + + esm-env@1.2.2: {} + + espree@10.4.0: + dependencies: + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) + eslint-visitor-keys: 4.2.1 + + esprima@4.0.1: {} + + esquery@1.6.0: + dependencies: + estraverse: 5.3.0 + + esrap@2.1.0: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + esutils@2.0.3: {} + + expect-type@1.2.2: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.19.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.3.3 + keyv: 4.5.4 + + flatted@3.3.3: {} + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globals@16.4.0: {} + + graceful-fs@4.2.11: {} + + graphemer@1.4.0: {} + + has-flag@4.0.0: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + human-id@4.1.2: {} + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + inline-style-parser@0.2.6: {} + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-module@1.0.0: {} + + is-number@7.0.0: {} + + is-reference@1.2.1: + dependencies: + '@types/estree': 1.0.8 + + is-reference@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + isexe@2.0.0: {} + + jiti@2.6.1: {} + + js-sha256@0.11.1: {} + + js-tokens@4.0.0: {} + + js-tokens@9.0.1: {} + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + keycloak-js@26.2.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kleur@4.1.5: {} + + known-css-properties@0.37.0: {} + + kysely@0.27.6: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-darwin-arm64@1.30.1: + optional: true + + lightningcss-darwin-x64@1.30.1: + optional: true + + lightningcss-freebsd-x64@1.30.1: + optional: true + + lightningcss-linux-arm-gnueabihf@1.30.1: + optional: true + + lightningcss-linux-arm64-gnu@1.30.1: + optional: true + + lightningcss-linux-arm64-musl@1.30.1: + optional: true + + lightningcss-linux-x64-gnu@1.30.1: + optional: true + + lightningcss-linux-x64-musl@1.30.1: + optional: true + + lightningcss-win32-arm64-msvc@1.30.1: + optional: true + + lightningcss-win32-x64-msvc@1.30.1: + optional: true + + lightningcss@1.30.1: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-darwin-arm64: 1.30.1 + lightningcss-darwin-x64: 1.30.1 + lightningcss-freebsd-x64: 1.30.1 + lightningcss-linux-arm-gnueabihf: 1.30.1 + lightningcss-linux-arm64-gnu: 1.30.1 + lightningcss-linux-arm64-musl: 1.30.1 + lightningcss-linux-x64-gnu: 1.30.1 + lightningcss-linux-x64-musl: 1.30.1 + lightningcss-win32-arm64-msvc: 1.30.1 + lightningcss-win32-x64-msvc: 1.30.1 + + lilconfig@2.1.0: {} + + locate-character@3.0.0: {} + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + loupe@3.2.1: {} + + lucide-svelte@0.553.0(svelte@5.40.2): + dependencies: + svelte: 5.40.2 + + lz-string@1.5.0: {} + + magic-string@0.30.19: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + marked@12.0.2: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mini-svg-data-uri@1.4.4: {} + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + + minipass@7.1.2: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.2 + + mri@1.2.0: {} + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + natural-compare@1.4.0: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.3: {} + + playwright-core@1.56.1: {} + + playwright@1.56.1: + dependencies: + playwright-core: 1.56.1 + optionalDependencies: + fsevents: 2.3.2 + + postcss-load-config@3.1.4(postcss@8.5.6): + dependencies: + lilconfig: 2.1.0 + yaml: 1.10.2 + optionalDependencies: + postcss: 8.5.6 + + postcss-safe-parser@7.0.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-scss@4.0.9(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-selector-parser@6.0.10: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-selector-parser@7.1.0: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.40.2): + dependencies: + prettier: 3.6.2 + svelte: 5.40.2 + + prettier-plugin-tailwindcss@0.7.1(prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.40.2))(prettier@3.6.2): + dependencies: + prettier: 3.6.2 + optionalDependencies: + prettier-plugin-svelte: 3.4.0(prettier@3.6.2)(svelte@5.40.2) + + prettier@3.6.2: {} + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + + react-is@17.0.2: {} + + readdirp@4.1.2: {} + + resolve-from@4.0.0: {} + + resolve@1.22.10: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + rollup@4.52.4: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.52.4 + '@rollup/rollup-android-arm64': 4.52.4 + '@rollup/rollup-darwin-arm64': 4.52.4 + '@rollup/rollup-darwin-x64': 4.52.4 + '@rollup/rollup-freebsd-arm64': 4.52.4 + '@rollup/rollup-freebsd-x64': 4.52.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.52.4 + '@rollup/rollup-linux-arm-musleabihf': 4.52.4 + '@rollup/rollup-linux-arm64-gnu': 4.52.4 + '@rollup/rollup-linux-arm64-musl': 4.52.4 + '@rollup/rollup-linux-loong64-gnu': 4.52.4 + '@rollup/rollup-linux-ppc64-gnu': 4.52.4 + '@rollup/rollup-linux-riscv64-gnu': 4.52.4 + '@rollup/rollup-linux-riscv64-musl': 4.52.4 + '@rollup/rollup-linux-s390x-gnu': 4.52.4 + '@rollup/rollup-linux-x64-gnu': 4.52.4 + '@rollup/rollup-linux-x64-musl': 4.52.4 + '@rollup/rollup-openharmony-arm64': 4.52.4 + '@rollup/rollup-win32-arm64-msvc': 4.52.4 + '@rollup/rollup-win32-ia32-msvc': 4.52.4 + '@rollup/rollup-win32-x64-gnu': 4.52.4 + '@rollup/rollup-win32-x64-msvc': 4.52.4 + fsevents: 2.3.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + runed@0.28.0(svelte@5.40.2): + dependencies: + esm-env: 1.2.2 + svelte: 5.40.2 + + runed@0.35.1(@sveltejs/kit@2.47.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2): + dependencies: + dequal: 2.0.3 + esm-env: 1.2.2 + lz-string: 1.5.0 + svelte: 5.40.2 + optionalDependencies: + '@sveltejs/kit': 2.47.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)) + + sade@1.8.1: + dependencies: + mri: 1.2.0 + + semver@7.7.3: {} + + set-cookie-parser@2.7.1: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + source-map-js@1.2.1: {} + + sqlite-wasm-kysely@0.3.0(kysely@0.27.6): + dependencies: + '@sqlite.org/sqlite-wasm': 3.48.0-build4 + kysely: 0.27.6 + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-json-comments@3.1.1: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + style-to-object@1.0.12: + dependencies: + inline-style-parser: 0.2.6 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + svelte-check@4.3.3(picomatch@4.0.3)(svelte@5.40.2)(typescript@5.9.3): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + chokidar: 4.0.3 + fdir: 6.5.0(picomatch@4.0.3) + picocolors: 1.1.1 + sade: 1.8.1 + svelte: 5.40.2 + typescript: 5.9.3 + transitivePeerDependencies: + - picomatch + + svelte-eslint-parser@1.3.3(svelte@5.40.2): + dependencies: + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + postcss: 8.5.6 + postcss-scss: 4.0.9(postcss@8.5.6) + postcss-selector-parser: 7.1.0 + optionalDependencies: + svelte: 5.40.2 + + svelte-sonner@1.0.7(svelte@5.40.2): + dependencies: + runed: 0.28.0(svelte@5.40.2) + svelte: 5.40.2 + + svelte-toolbelt@0.10.6(@sveltejs/kit@2.47.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2): + dependencies: + clsx: 2.1.1 + runed: 0.35.1(@sveltejs/kit@2.47.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2) + style-to-object: 1.0.12 + svelte: 5.40.2 + transitivePeerDependencies: + - '@sveltejs/kit' + + svelte@5.40.2: + dependencies: + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.5.5 + '@sveltejs/acorn-typescript': 1.0.6(acorn@8.15.0) + '@types/estree': 1.0.8 + acorn: 8.15.0 + aria-query: 5.3.2 + axobject-query: 4.1.0 + clsx: 2.1.1 + esm-env: 1.2.2 + esrap: 2.1.0 + is-reference: 3.0.3 + locate-character: 3.0.0 + magic-string: 0.30.19 + zimmerframe: 1.1.4 + + tabbable@6.3.0: {} + + tailwind-merge@3.3.1: {} + + tailwind-variants@3.1.1(tailwind-merge@3.3.1)(tailwindcss@4.1.14): + dependencies: + tailwindcss: 4.1.14 + optionalDependencies: + tailwind-merge: 3.3.1 + + tailwindcss@4.1.14: {} + + tapable@2.3.0: {} + + tar@7.5.1: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.2 + minizlib: 3.1.0 + yallist: 5.0.0 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + totalist@3.0.1: {} + + ts-api-utils@2.1.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + tslib@2.8.1: {} + + tw-animate-css@1.4.0: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.46.1(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.46.1(@typescript-eslint/parser@8.46.1(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.46.1(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.46.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.1(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.38.0(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + unplugin@2.3.10: + dependencies: + '@jridgewell/remapping': 2.3.5 + acorn: 8.15.0 + picomatch: 4.0.3 + webpack-virtual-modules: 0.6.2 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + urlpattern-polyfill@10.1.0: {} + + util-deprecate@1.0.2: {} + + uuid@10.0.0: {} + + vite-node@3.2.4(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1): + dependencies: + esbuild: 0.25.11 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.52.4 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 20.19.22 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.1 + + vitefu@1.1.1(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)): + optionalDependencies: + vite: 7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1) + + vitest-browser-svelte@1.1.0(@vitest/browser@3.2.4)(svelte@5.40.2)(vitest@3.2.4): + dependencies: + '@vitest/browser': 3.2.4(playwright@1.56.1)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1))(vitest@3.2.4) + svelte: 5.40.2 + vitest: 3.2.4(@types/node@20.19.22)(@vitest/browser@3.2.4)(jiti@2.6.1)(lightningcss@1.30.1) + + vitest@3.2.4(@types/node@20.19.22)(@vitest/browser@3.2.4)(jiti@2.6.1)(lightningcss@1.30.1): + dependencies: + '@types/chai': 5.2.2 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.2.2 + magic-string: 0.30.19 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1) + vite-node: 3.2.4(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.22 + '@vitest/browser': 3.2.4(playwright@1.56.1)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1))(vitest@3.2.4) + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + webpack-virtual-modules@0.6.2: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + ws@8.18.3: {} + + yallist@5.0.0: {} + + yaml@1.10.2: {} + + yocto-queue@0.1.0: {} + + zimmerframe@1.1.4: {} diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml new file mode 100644 index 0000000..8d1cdf3 --- /dev/null +++ b/frontend/pnpm-workspace.yaml @@ -0,0 +1,6 @@ +packages: + - '.' + +onlyBuiltDependencies: + - esbuild + - '@tailwindcss/oxide' diff --git a/frontend/project.inlang/.gitignore b/frontend/project.inlang/.gitignore new file mode 100644 index 0000000..5e46596 --- /dev/null +++ b/frontend/project.inlang/.gitignore @@ -0,0 +1 @@ +cache \ No newline at end of file diff --git a/frontend/project.inlang/project_id b/frontend/project.inlang/project_id new file mode 100644 index 0000000..4d66687 --- /dev/null +++ b/frontend/project.inlang/project_id @@ -0,0 +1 @@ +UYEx30XMEoBHyXSEuC \ No newline at end of file diff --git a/frontend/project.inlang/settings.json b/frontend/project.inlang/settings.json new file mode 100644 index 0000000..a9ba28f --- /dev/null +++ b/frontend/project.inlang/settings.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://inlang.com/schema/project-settings", + "modules": [ + "https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js", + "https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js" + ], + "plugin.inlang.messageFormat": { + "pathPattern": "./messages/{locale}.json" + }, + "baseLocale": "es", + "locales": [ + "en", + "es" + ] +} diff --git a/frontend/scripts/merge_csv_upload_i18n.py b/frontend/scripts/merge_csv_upload_i18n.py new file mode 100644 index 0000000..be3d33d --- /dev/null +++ b/frontend/scripts/merge_csv_upload_i18n.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python3 +"""Fusiona bloques csv_upload en messages/en.json y messages/es.json y vuelca a src/lib/i18n/csv-upload-messages.*.json. + +La fuente de verdad del copy CSV es `messages/{en,es}.json` (alineado con sidebar, dashboard, facturas). +Si editas solo esos JSON, sincroniza con: + node -e "const fs=require('fs'),p=require('path'),r='.../frontend';for(const l of['en','es']){const j=JSON.parse(fs.readFileSync(p.join(r,'messages',l+'.json'),'utf8'));fs.writeFileSync(p.join(r,'src/lib/i18n','csv-upload-messages.'+l+'.json'),JSON.stringify(j.csv_upload,null,'\\t')+'\\n')}" + +Ejecutar desde frontend/: python scripts/merge_csv_upload_i18n.py +""" +from __future__ import annotations + +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +MESSAGES = ROOT / "messages" + +EN_CSV = { + "page_title": "CSV import", + "intro_help": "Left-click: upload CSV file. Right-click: download template.", + "tab_catalogos": "Catalogs", + "tab_transportes": "Transportation", + "tab_importacion": "Import", + "tab_exportacion": "Export", + "section_catalogs": "General Catalogs", + "section_transport": "Transportation", + "section_import": "Import operations", + "section_export": "Export operations", + "params_header": "Global parameters", + "config_prefix": "Settings", + "soon": "Coming soon", + "drop_here": "Drop the file!", + "groups": { + "permisos": "Permissions", + "impo_temp": "Temporary import", + "impo_def": "Definitive import", + "cmex": "Mexican purchases", + "expo_def": "Definitive export / regime change", + "expo_rep": "Export replenishment", + "manifest": "Manifest", + }, + "items": { + "customs_brokers": "Customs Brokers", + "clients_providers": "Clients and Providers", + "exchange_rates": "Exchange Rates", + "material_classes": "Classes", + "part_numbers": "Parts", + "boms": "BOMs", + "items": "Lines (permissions)", + "headers": "Headers (permissions)", + "historical_fractions": "Historical tariff fractions", + "pedimentos": "Pedimentos", + "transporters": "Carriers", + "transports": "Vehicles", + "drivers": "Drivers", + "trailers": "Trailers", + "imp_temp_header": "Header", + "imp_temp_details": "Lines", + "imp_temp_series": "Serial numbers", + "imp_def_header": "Header", + "imp_def_details": "Lines", + "imp_def_series": "Serial numbers", + "comp_mex_header": "Header", + "comp_mex_details": "Lines", + "comp_mex_series": "Serial numbers", + "exp_def_header": "Header", + "exp_def_details": "Lines", + "exp_def_series": "Serial numbers", + "exp_def_nodes": "NODES", + "exp_rep_header": "Header", + "exp_rep_details": "Lines", + "exp_rep_series": "Serial numbers", + "manifest_header": "Header", + }, + "params": { + "load_mode": "Load mode", + "date_format": "Date format", + "weight_unit": "Weight unit", + "autonumber_series": "Autonumber lines/series", + "load_subpartidas": "Load sub-lines", + "recalculate_pedimento_date": "Recalculate pedimento date", + "autonumber_remesas": "Autonumber consignments", + "recalculate_dates": "Recalculate dates", + "invoice_type": "Invoice type", + "is_regime_change": "Regime change", + }, + "options": { + "update": "Update", + "replace": "Replace", + "yes": "Yes", + "no": "No", + "kgs": "Kilograms (kg)", + "lbs": "Pounds (lb)", + "date_dd_mm": "DD/MM/YYYY", + "date_mm_dd": "MM/DD/YYYY", + "date_iso": "YYYY-MM-DD", + "afi": "AFIJO", + "normal": "NORMAL", + }, + "progress": { + "upload": "Uploading CSV file", + "scan": "Validating records on the server", + "commit": "Saving records to the database", + "upload_known": "Uploading file…", + "upload_unknown": "Uploading file (unknown size in browser)…", + "in_progress": "In progress…", + "resume_hint": "Resuming import saved in this tab…", + "rows_file": "File: ~{n} data row(s) — uploading (not yet validated on server)…", + "rows_scan": "Records processed: {current} / {total}", + "rows_commit": "Records saved: {current} / {total}", + "rows_commit_fallback": "Saving to database… ({current} / {total} using last known total)", + }, + "toast": { + "invalid_csv": "Invalid format. Only .csv files are allowed.", + "download_loading": "Downloading template…", + "download_ok": "Template downloaded.", + "download_err": "Could not download the template.", + "upload_err": "Could not upload the file.", + "upload_err_generic": "Unexpected error uploading the file.", + "scan_done": "Scan complete. Review the results.", + "import_done": "Import completed. Review the record list.", + "import_maybe_done": "Import may have completed. Review the record list.", + "stale_job": "This import is no longer available (session expired or job removed). You can start a new upload.", + "poll_err": "Could not fetch status", + "commit_err": "Could not start import", + "scan_alt": "Scan finished. If you do not see the modal, check the record list.", + "finished_none": "No records inserted. Review the errors below.", + "commit_warning_ok": "{inserted} inserted, {updated} updated. {skipped} rejected.", + "commit_warning_none": "No records inserted or updated. {skipped} rejected.", + "success_counts": "Import completed: {msg}", + "warn_skipped": "{n} records rejected or skipped", + "error_processing": "Processing error: {msg}", + }, + "pending": { + "badge": "Pending", + "title": "Imports pending confirmation", + "description": "Scans ready to save to the database. Expired jobs disappear when you refresh.", + "refresh": "Refresh", + "empty": "No pending imports for this company.", + "checking": "Checking with the server…", + "total_rows": "Total rows", + "valid_rows": "Valid", + "resume": "Resume", + "remove": "Remove", + "profiles": { + "customs_brokers": "Customs Brokers", + "clients_providers": "Clients and Providers", + "exchange_rates": "Exchange Rates", + "pedimentos": "Pedimentos", + "material_classes": "Classes", + "vehicles": "Vehicles", + "drivers": "Drivers", + "trailers": "Trailers", + "transporters": "Carriers", + "part_numbers": "Parts", + "boms": "BOMs", + "exportacion": "Export operations", + "imports": "Import operations", + }, + }, + "modal": { + "title_pending": "Import validation", + "title_success": "Import successful", + "title_warning": "Import with remarks", + "desc_pending": "Review the preliminary analysis before confirming.", + "desc_done": "The import process has finished.", + "total_rows": "Total rows", + "valid_rows": "Valid", + "invalid_rows": "Invalid", + }, +} + +ES_CSV = { + "page_title": "Importación CSV", + "intro_help": "Clic izquierdo: cargar archivo CSV. Clic derecho: descargar plantilla.", + "tab_catalogos": "Catálogos", + "tab_transportes": "Transportes", + "tab_importacion": "Importación", + "tab_exportacion": "Exportación", + "section_catalogs": "Catalogos Generales", + "section_transport": "Transportes", + "section_import": "Operaciones de importación", + "section_export": "Operaciones de exportación", + "params_header": "Parámetros globales", + "config_prefix": "Configuración", + "soon": "Próximamente", + "drop_here": "¡Suelta el archivo!", + "groups": { + "permisos": "Permisos", + "impo_temp": "Impo. temp.", + "impo_def": "Impo. def.", + "cmex": "Compras mex.", + "expo_def": "Expo. def./Cam. reg.", + "expo_rep": "Expo. rep.", + "manifest": "Manifiesto", + }, + "items": { + "customs_brokers": "Agentes Aduanales", + "clients_providers": "Clientes y Proveedores", + "exchange_rates": "Tipos de cambio", + "material_classes": "Clases", + "part_numbers": "Partes", + "boms": "BOMs", + "items": "Partidas (permisos)", + "headers": "Encabezados (permisos)", + "historical_fractions": "Fracciones históricas", + "pedimentos": "Pedimentos", + "transporters": "Transportistas", + "transports": "Vehículos", + "drivers": "Conductores", + "trailers": "Trailers", + "imp_temp_header": "Encabezado", + "imp_temp_details": "Partidas", + "imp_temp_series": "Series", + "imp_def_header": "Encabezado", + "imp_def_details": "Partidas", + "imp_def_series": "Series", + "comp_mex_header": "Encabezado", + "comp_mex_details": "Partidas", + "comp_mex_series": "Series", + "exp_def_header": "Encabezado", + "exp_def_details": "Partidas", + "exp_def_series": "Series", + "exp_def_nodes": "NODES", + "exp_rep_header": "Encabezado", + "exp_rep_details": "Partidas", + "exp_rep_series": "Series", + "manifest_header": "Encabezado", + }, + "params": { + "load_mode": "Modo de carga", + "date_format": "Formato de fecha", + "weight_unit": "Unidad de peso", + "autonumber_series": "Autonumerar partidas/series", + "load_subpartidas": "Levantar subpartidas", + "recalculate_pedimento_date": "Recalcular fecha pedimento", + "autonumber_remesas": "Autonumerar remesas", + "recalculate_dates": "Recalcular fechas", + "invoice_type": "Tipo de factura", + "is_regime_change": "Es cambio de régimen", + }, + "options": { + "update": "Actualizar", + "replace": "Reemplazar", + "yes": "Sí", + "no": "No", + "kgs": "Kilos (kg)", + "lbs": "Libras (lb)", + "date_dd_mm": "DD/MM/YYYY", + "date_mm_dd": "MM/DD/YYYY", + "date_iso": "YYYY-MM-DD", + "afi": "AFIJO", + "normal": "NORMAL", + }, + "progress": { + "upload": "Subiendo archivo CSV", + "scan": "Validando registros en el servidor", + "commit": "Grabando registros en base de datos", + "upload_known": "Subiendo archivo…", + "upload_unknown": "Subiendo archivo (tamaño desconocido en el navegador)…", + "in_progress": "En proceso…", + "resume_hint": "Reanudando la importación guardada en esta pestaña…", + "rows_file": "Archivo: ~{n} fila(s) de datos — subiendo (aún no se validan registros en servidor)…", + "rows_scan": "Registros procesados: {current} / {total}", + "rows_commit": "Registros grabados: {current} / {total}", + "rows_commit_fallback": "Grabando en base de datos… ({current} / {total} según último total conocido)", + }, + "toast": { + "invalid_csv": "Formato inválido. Solo se permiten archivos .csv", + "download_loading": "Descargando plantilla…", + "download_ok": "Plantilla descargada.", + "download_err": "Error al descargar la plantilla", + "upload_err": "Error al subir el archivo", + "upload_err_generic": "Error inesperado al subir el archivo", + "scan_done": "Escaneo completado. Revisa los resultados.", + "import_done": "Importación completada. Revisa el listado de registros.", + "import_maybe_done": "La importación pudo completarse. Revisa el listado de registros.", + "stale_job": "Esta importación ya no está disponible (sesión expirada o trabajo eliminado). Puedes iniciar una nueva carga.", + "poll_err": "Error al consultar el estado", + "commit_err": "Error al iniciar la importación", + "scan_alt": "El escaneo terminó. Si no ves el modal, revisa el listado de registros.", + "finished_none": "No se insertaron registros. Revisa los errores a continuación.", + "commit_warning_ok": "{inserted} insertados, {updated} actualizados. {skipped} rechazados.", + "commit_warning_none": "No se insertaron ni actualizaron registros. {skipped} rechazados.", + "success_counts": "Importación completada: {msg}", + "warn_skipped": "{n} registros fueron rechazados u omitidos", + "error_processing": "Error en el procesamiento: {msg}", + }, + "pending": { + "badge": "Pendientes", + "title": "Importaciones pendientes de confirmar", + "description": "Escaneos listos para insertar en base de datos. Si el trabajo ya expiró en el servidor, desaparecerán al actualizar.", + "refresh": "Actualizar", + "empty": "No hay importaciones pendientes para esta empresa.", + "checking": "Comprobando con el servidor…", + "total_rows": "Total filas", + "valid_rows": "Válidas", + "resume": "Reanudar", + "remove": "Quitar", + "profiles": { + "customs_brokers": "Agentes Aduanales", + "clients_providers": "Clientes y Proveedores", + "exchange_rates": "Tipos de cambio", + "pedimentos": "Pedimentos", + "material_classes": "Clases", + "vehicles": "Vehículos", + "drivers": "Conductores", + "trailers": "Trailers", + "transporters": "Transportistas", + "part_numbers": "Partes", + "boms": "BOMs", + "exportacion": "Exportación (operaciones)", + "imports": "Importación (operaciones)", + }, + }, + "modal": { + "title_pending": "Validación de importación", + "title_success": "Importación exitosa", + "title_warning": "Importación con observaciones", + "desc_pending": "Revise el análisis preliminar antes de confirmar la carga de datos.", + "desc_done": "El proceso de importación ha finalizado.", + "total_rows": "Total filas", + "valid_rows": "Válidos", + "invalid_rows": "Inválidos", + }, +} + + +def merge_locale(filename: str, csv_obj: dict) -> None: + path = MESSAGES / filename + data = json.loads(path.read_text(encoding="utf-8")) + data["csv_upload"] = csv_obj + path.write_text(json.dumps(data, ensure_ascii=False, indent="\t") + "\n", encoding="utf-8") + + +def extract_csv_upload_to_lib() -> None: + """Copia `csv_upload` a src/lib/i18n/csv-upload-messages.*.json (lo que importa csv-msg.ts).""" + dest_dir = ROOT / "src/lib/i18n" + for filename, suffix in (("en.json", "en"), ("es.json", "es")): + path = MESSAGES / filename + data = json.loads(path.read_text(encoding="utf-8")) + cu = data.get("csv_upload") + if cu is None: + raise SystemExit(f"merge_csv_upload_i18n: falta csv_upload en {filename}") + out = dest_dir / f"csv-upload-messages.{suffix}.json" + out.write_text(json.dumps(cu, ensure_ascii=False, indent="\t") + "\n", encoding="utf-8") + print(f"Wrote {out.relative_to(ROOT)}") + + +def main() -> None: + merge_locale("en.json", EN_CSV) + merge_locale("es.json", ES_CSV) + print("Merged csv_upload into en.json and es.json") + extract_csv_upload_to_lib() + + +if __name__ == "__main__": + main() diff --git a/frontend/src/app.css b/frontend/src/app.css new file mode 100644 index 0000000..990d5f9 --- /dev/null +++ b/frontend/src/app.css @@ -0,0 +1,188 @@ +@import 'tailwindcss'; + +@plugin '@tailwindcss/forms'; +@plugin '@tailwindcss/typography'; + +@custom-variant dark (&:is(.dark *)); + +:root { + --radius: 0.65rem; + --background: oklch(1 0 0); + --foreground: oklch(0.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.623 0.214 259.815); + --primary-foreground: oklch(0.97 0.014 254.604); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.623 0.214 259.815); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.141 0.005 285.823); + --sidebar-primary: oklch(0.623 0.214 259.815); + --sidebar-primary-foreground: oklch(0.97 0.014 254.604); + --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent-foreground: oklch(0.21 0.006 285.885); + --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-ring: oklch(0.623 0.214 259.815); + color-scheme: light; +} + +.dark { + --background: oklch(0.141 0.005 285.823); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.546 0.245 262.881); + --primary-foreground: oklch(0.98 0.01 262.881); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.488 0.243 264.376); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.21 0.006 285.885); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.546 0.245 262.881); + --sidebar-primary-foreground: oklch(0.379 0.146 265.522); + --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.488 0.243 264.376); + color-scheme: dark; +} + + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground overflow-x-hidden; + } + + /* Asegurar que el texto de los inputs de fecha sea legible en modo oscuro + y en navegadores WebKit */ + input[type="date"], + input[type="datetime-local"] { + color: var(--color-foreground); + -webkit-text-fill-color: var(--color-foreground); + } + + input[type="date"]::-webkit-calendar-picker-indicator, + input[type="datetime-local"]::-webkit-calendar-picker-indicator { + display: none; + opacity: 0; + } + + .dark input[type="date"]::-webkit-calendar-picker-indicator, + .dark input[type="datetime-local"]::-webkit-calendar-picker-indicator { + cursor: pointer; + filter: invert(1) brightness(1.15); + opacity: 0.9; + } +} + +@layer components { + .catalog-table-shell { + @apply rounded-md border border-border/80 bg-card shadow-sm; + } + + .catalog-table-scroll { + @apply relative w-full flex-1 overflow-auto bg-card; + } + + .catalog-table-header { + @apply sticky top-0 z-20 border-b border-border/80 bg-card/95 shadow-sm backdrop-blur-md; + } + + .catalog-table-head-cell { + @apply whitespace-nowrap text-sm font-semibold text-foreground/90; + } + + .catalog-table-row { + @apply transition-colors hover:bg-accent/35; + } + + .catalog-table-row-selected { + @apply bg-accent/65 text-accent-foreground hover:bg-accent/65; + } + + .catalog-table-sticky-left { + @apply sticky left-0 border-r border-border/70 bg-card shadow-[4px_0_12px_-6px_rgba(0,0,0,0.12)] dark:shadow-[4px_0_12px_-6px_rgba(0,0,0,0.35)]; + } + + .catalog-table-sticky-right { + @apply sticky right-0 border-l border-border/70 bg-card shadow-[-4px_0_12px_-6px_rgba(0,0,0,0.08)] dark:shadow-[-4px_0_12px_-6px_rgba(0,0,0,0.25)]; + } + + .catalog-table-sticky-row-hover { + @apply bg-card group-hover/inv-list:bg-accent/35; + } + + .catalog-table-sticky-row-selected { + @apply bg-accent/65 text-accent-foreground; + } +} diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts new file mode 100644 index 0000000..3895702 --- /dev/null +++ b/frontend/src/app.d.ts @@ -0,0 +1,22 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + interface Locals { + token: string | null; + isAuthenticated: boolean; + } + interface PageData { + licenseError?: { + type: string; + message: string; + status: number; + }; + } + // interface PageState {} + // interface Platform {} + } +} + +export { }; diff --git a/frontend/src/app.html b/frontend/src/app.html new file mode 100644 index 0000000..50c211d --- /dev/null +++ b/frontend/src/app.html @@ -0,0 +1,22 @@ + + + + + + Mi Aplicación + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/frontend/src/demo.spec.ts b/frontend/src/demo.spec.ts new file mode 100644 index 0000000..e07cbbd --- /dev/null +++ b/frontend/src/demo.spec.ts @@ -0,0 +1,7 @@ +import { describe, it, expect } from 'vitest'; + +describe('sum test', () => { + it('adds 1 + 2 to equal 3', () => { + expect(1 + 2).toBe(3); + }); +}); diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts new file mode 100644 index 0000000..b4648d1 --- /dev/null +++ b/frontend/src/hooks.server.ts @@ -0,0 +1,25 @@ +import type { Handle } from '@sveltejs/kit'; +import { paraglideMiddleware } from '$lib/paraglide/server'; +import { sequence } from '@sveltejs/kit/hooks'; +import { getAccessTokenFromCookies } from '$lib/server/access-token-cookie'; + +const handleParaglide: Handle = ({ event, resolve }) => paraglideMiddleware(event.request, ({ request, locale }) => { + event.request = request; + + return resolve(event, { + transformPageChunk: ({ html }) => html.replace('%paraglide.lang%', locale) + }); +}); + +const handleAuth: Handle = async ({ event, resolve }) => { + // Obtener el token de las cookies + const token = getAccessTokenFromCookies(event.cookies); + + // Agregar el token a los locals para que esté disponible en toda la app + event.locals.token = token || null; + event.locals.isAuthenticated = !!token; + + return resolve(event); +}; + +export const handle: Handle = sequence(handleAuth, handleParaglide); diff --git a/frontend/src/hooks.ts b/frontend/src/hooks.ts new file mode 100644 index 0000000..3b0e6d0 --- /dev/null +++ b/frontend/src/hooks.ts @@ -0,0 +1,4 @@ +import { deLocalizeUrl } from '$lib/paraglide/runtime'; +import type { RequestEvent } from '@sveltejs/kit'; + +export const reroute = (request: { url: string }) => deLocalizeUrl(request.url).pathname; diff --git a/frontend/src/lib/Reporte_Pruebas.MD b/frontend/src/lib/Reporte_Pruebas.MD new file mode 100644 index 0000000..5383e6c --- /dev/null +++ b/frontend/src/lib/Reporte_Pruebas.MD @@ -0,0 +1,259 @@ +# Reporte de Pruebas — Anexo 76 + +## Resumen ejecutivo + +| Herramienta | Archivos | Pruebas | Estado | +|-------------|----------|---------|--------| +| Backend — pytest | 4 | 11 | Pasando | +| Frontend — Vitest (server) | 8 | 68 | Pasando | +| Frontend — Playwright (E2E) | 5 | 28 | Pasando | +| **Total** | **17** | **107** | **Pasando** | + +--- + +## Backend — pytest (11 pruebas) + +Ubicacion: `backend/tests/` + +### e2e/test_inventory_flow_anexo24.py — 1 prueba + +**test_e2e_inventory_flow_import_then_export** — la prueba mas importante del repositorio. Simula el flujo completo del negocio: + +1. Crea una factura de importacion TEM con 10 piezas +2. La procesa — genera movimiento ENTRY en el inventario +3. Crea una factura de exportacion consumiendo 4 piezas +4. La procesa — genera CONSUMPTION y DISCHARGE +5. Verifica que el saldo neto es positivo y menor a 10 + +### integration/ — 5 pruebas + +- **test_process_export_endpoint_consumes_existing_balances** — exportacion consume saldos existentes correctamente +- **test_process_export_prevents_negative_balance** — exportacion no puede consumir mas de lo que hay +- **test_process_endpoint_prevents_double_processing_import** — una factura no se puede procesar dos veces +- **test_process_import_endpoint_creates_balance_entries** — importacion TEM genera entradas de balance +- **test_process_import_def_does_not_create_balance_entries** — importacion DEF no genera entradas de balance + +### unit/ — 5 pruebas + +- **test_net_balance_accounts_for_returns_and_entry_void** — balance neto calcula correctamente entradas, consumos, devoluciones y anulaciones +- **test_fifo_consumption_algorithm_uses_oldest_lots_first** — algoritmo PEPS consume primero los lotes mas antiguos +- **test_assign_values_iva_lines_currency_me** — calculo de IVA en moneda extranjera +- **test_assign_values_iva_lines_currency_mn** — calculo de IVA en moneda local +- **test_assign_values_iva_lines_currency_mc** — calculo de IVA en moneda manual + +### Comando + +```bash +docker exec -it anexo76-backend pytest /app/tests/ -v +``` + +--- + +## Frontend Vitest — 68 pruebas + +Vitest corre en Node sin navegador. Prueba funciones puras que reciben datos y devuelven un resultado. + +### backend.test.ts — 2 pruebas + +Verifica que el backend esta disponible desde el contenedor del frontend usando la red interna de Docker. + +- **el backend esta corriendo y responde** — GET /api/health devuelve 200 +- **el endpoint de facturas responde** — el endpoint de invoices responde (200, 401, 403 o 422 son validos) + +Nota: no se usa `docker exec` porque el contenedor del frontend no tiene acceso a Docker. Se usa la URL interna `http://backend:8000` de la red Docker Compose. + +### utils.getInvoiceTypeColor.test.ts — 19 pruebas + +Prueba que cada tipo de factura devuelve el color Tailwind correcto para mostrarse en la UI. + +- Entradas invalidas (null, undefined, '') devuelven gris por defecto +- TEM / IMPO TEM -> rojo (bg-red-100) +- DEF / IMPO DEF -> verde (bg-green-100) +- MEX / COMP MEX -> morado (bg-purple-100) +- CAM REG -> azul (bg-blue-100) +- EXPO / EXDEF / PTERM -> azul (bg-blue-100) +- IMP REP -> azul claro (bg-sky-100) +- Tipo desconocido -> gris por defecto + +### utils.getFileHelpers.test.ts — 9 pruebas + +Prueba extraccion de nombres de archivo desde rutas y formateo para mostrar al usuario. + +- null / undefined -> string vacio +- Ruta normal '/uploads/file.png' -> 'file.png' +- Ruta con query string '/uploads/file.png?token=123' -> 'file.png' sin el token +- Con fileType -> 'file.png (PDF)' + +### utils.getBackendAssetUrl.test.ts — 5 pruebas + +Prueba construccion de URLs del backend evitando duplicar /api. + +Deuda tecnica identificada: la funcion depende de import.meta.env.VITE_API_URL. Se pasa baseUrl explicitamente en cada test. + +- null / undefined -> string vacio +- URL completa -> se devuelve sin modificar +- /api/... con base /api -> evita /api/api/ +- Ruta normal -> URL completa correcta + +### date-utils.test.ts — 17 pruebas + +Prueba conversion de fechas entre zona local y UTC ISO. + +- prepareDateForBackend: vacio -> null, fecha + hora -> ISO string valido +- loadServerDate: null/undefined -> '', ISO UTC -> YYYY-MM-DD, fecha plana sin modificar +- addDaysLocal: vacio -> '', suma normal, cambio de mes, ano bisiesto (2024-02-29) +- getCurrentLocal*: verifica formato con regex — no valor exacto porque cambia cada dia + +### csv-import-commit-metrics.test.ts — 11 pruebas + +Prueba metricas del resultado de importacion CSV. + +- totalSkippedFromCommit: null -> 0, objeto vacio -> 0, suma correcta de campos skipped +- criticalReferenceGaps: null -> 0, objeto vacio -> 0, campo presente -> su valor +- referenceStateReady: null -> true, campo booleano respeta el valor + +### csv-import-status-api.test.ts — 4 pruebas + +Prueba isWaitingConfirmationPayload. fetchCsvImportStatus no se prueba porque depende del backend. + +- null / {} -> false +- { status: 'waiting_confirmation' } -> true +- { job_id: 'abc', total_rows: 10 } -> true + +### Comando + +```bash +docker exec -it anexo76-frontend pnpm test:unit --project server +``` + +--- + +## Frontend Playwright E2E — 28 pruebas + +Playwright abre un navegador real y prueba flujos completos con el backend y Keycloak levantados. + +### Configuracion + +- **workers: 1** — las pruebas con login no pueden correr en paralelo +- **headless: false** — necesario para estabilidad en Windows +- **timeout: 60000** — Keycloak puede tardar hasta 60 segundos +- **storageState** — sesion guardada en e2e/.auth/user.json y reutilizada + +### auth.setup.ts — 1 prueba + +Login inicial con credenciales demo/demo123. Guarda la sesion para reutilizar. + +### full-flow.spec.ts — 4 pruebas + +Conecta backend y frontend — corre pytest primero y si pasa, verifica el frontend. + +- **pruebas del backend pasan antes de continuar** — ejecuta pytest /app/tests/ y verifica 11 passed +- **Import Invoices TEM carga despues de que backend pasa** — URL /invoices + main visible +- **Export Invoices carga despues de que backend pasa** — URL /invoices + main visible +- **Reportes de facturas carga despues de que backend pasa** — URL /reports + main visible + +### login.spec.ts — 3 pruebas + +- Login exitoso redirige al dashboard +- Dashboard muestra h1 visible +- Credenciales incorrectas se quedan en /login + +### navigation.spec.ts — 10 pruebas + +- Dashboard carga con h1 visible +- Header muestra Aduanasoft S.A. de C.V. +- Menu lateral tiene Audit Logs, Customs Brokers, Fractions, Pedimentos +- Audit Logs, Customs Brokers, Fraction Sitar y Pedimentos cargan sin error + +### modules.spec.ts — 10 pruebas + +- Import Invoices TEM y DEF cargan sin error +- Export Invoices carga sin error +- Fixed Catalogs, General Catalogs, Transportes, Goods, Settings, Reportes cargan sin error +- Logout redirige a /login + +### Comando + +```bash +# Desde la carpeta frontend en Windows +pnpm test:e2e + +# Suite especifica +pnpm test:e2e --grep "Flujo completo" +pnpm test:e2e --grep "Login" +pnpm test:e2e --grep "Modulos" +pnpm test:e2e --grep "Navegacion" +``` + +--- + +## Archivos que NO se prueban con Vitest + +| Archivo | Razon | +|---------|-------| +| api.ts | Depende de fetch, cookies, window, document | +| auth.ts | Depende de Keycloak JS, cookies, window | +| session-manager.ts | Depende de window, setInterval, eventos DOM | +| sso.ts | Depende de Keycloak JS y browser | +| csv-import-pending.ts | Depende de localStorage | +| csv-import-session.ts | Depende de sessionStorage | +| csv-upload-row-count.ts | Depende de File.text(), API del browser | +| fetchCsvImportStatus | Depende de api que necesita el backend | + +--- + +## Pruebas pendientes para el futuro + +### Necesitan datos en la BD +- Buscar una factura especifica en Import Invoices +- Filtrar por fecha en Reportes +- Verificar que tablas muestran registros tras procesar una factura + +### Necesitan mas usuarios +- RBAC: usuario sin permisos intenta acceder a ruta protegida +- Admin vs usuario normal ven opciones distintas + +### Necesitan flujo completo +- Subir un CSV y verificar que el proceso funciona +- Crear una factura y verificar que aparece en la tabla + +--- + +## Estructura de archivos final + +``` +backend/tests/ +├── conftest.py +├── fixtures/ +│ └── builders.py +├── e2e/ +│ └── test_inventory_flow_anexo24.py 1 test +├── integration/ +│ ├── test_export_process_api.py 3 tests +│ └── test_import_process_api.py 2 tests +└── unit/ + └── invoices/ + ├── test_balance_algorithm.py 2 tests + └── test_currency_conversion.py 3 tests + +frontend/src/lib/ +├── backend.test.ts 2 tests +├── utils.getInvoiceTypeColor.test.ts 19 tests +├── utils.getFileHelpers.test.ts 9 tests +├── utils.getBackendAssetUrl.test.ts 5 tests +├── date-utils.test.ts 17 tests +├── csv-import-commit-metrics.test.ts 11 tests +└── csv-import-status-api.test.ts 4 tests + +frontend/e2e/ +├── .auth/user.json +├── auth.setup.ts 1 test +├── full-flow.spec.ts 4 tests +├── login.spec.ts 3 tests +├── navigation.spec.ts 10 tests +└── modules.spec.ts 10 tests +``` + +--- + +*Anexo 76 — Reporte de Pruebas v3.0 — 107 pruebas totales* \ No newline at end of file diff --git a/frontend/src/lib/access-token-cookie-browser.ts b/frontend/src/lib/access-token-cookie-browser.ts new file mode 100644 index 0000000..a7ab23d --- /dev/null +++ b/frontend/src/lib/access-token-cookie-browser.ts @@ -0,0 +1,71 @@ +import { browser } from '$app/environment'; +import { + ACCESS_TOKEN_CHUNK_COUNT, + accessTokenChunkName, + splitAccessTokenForCookies, + ACCESS_TOKEN_MAX_CHUNKS +} from '$lib/access-token-cookie.shared'; + +function readCookieRaw(name: string): string | null { + if (!browser) return null; + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return parts.pop()?.split(';').shift() ?? null; + return null; +} + +export function getAccessTokenFromDocument(): string | null { + if (!browser) return null; + const countRaw = readCookieRaw(ACCESS_TOKEN_CHUNK_COUNT); + if (countRaw) { + const n = parseInt(countRaw, 10); + if (!Number.isFinite(n) || n < 1 || n > ACCESS_TOKEN_MAX_CHUNKS) return null; + let out = ''; + for (let i = 0; i < n; i++) { + const p = readCookieRaw(accessTokenChunkName(i)); + if (p == null) return null; + out += p; + } + return out; + } + return readCookieRaw('access_token'); +} + +export function hasAccessTokenInDocument(): boolean { + if (!browser) return false; + return !!(readCookieRaw('access_token') || readCookieRaw(ACCESS_TOKEN_CHUNK_COUNT)); +} + +export function clearAccessTokenOnDocument() { + if (!browser) return; + const secure = window.location.protocol === 'https:' ? '; Secure' : ''; + const blank = `; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; SameSite=Lax${secure}`; + const clear = (name: string) => { + document.cookie = `${name}=${blank}`; + }; + clear('access_token'); + clear(ACCESS_TOKEN_CHUNK_COUNT); + for (let i = 0; i < ACCESS_TOKEN_MAX_CHUNKS; i++) { + clear(accessTokenChunkName(i)); + } +} + +/** Misma política que auth setCookie: expires + SameSite + Secure en HTTPS. */ +export function setAccessTokenOnDocument(token: string, days: number = 7) { + if (!browser) return; + clearAccessTokenOnDocument(); + const exp = new Date(); + exp.setDate(exp.getDate() + days); + const secure = window.location.protocol === 'https:' ? '; Secure' : ''; + const suffix = `; path=/; expires=${exp.toUTCString()}; SameSite=Lax${secure}`; + + const split = splitAccessTokenForCookies(token); + if (split.kind === 'single') { + document.cookie = `access_token=${split.value}${suffix}`; + return; + } + document.cookie = `${ACCESS_TOKEN_CHUNK_COUNT}=${split.parts.length}${suffix}`; + split.parts.forEach((part, i) => { + document.cookie = `${accessTokenChunkName(i)}=${part}${suffix}`; + }); +} diff --git a/frontend/src/lib/access-token-cookie.shared.ts b/frontend/src/lib/access-token-cookie.shared.ts new file mode 100644 index 0000000..8476645 --- /dev/null +++ b/frontend/src/lib/access-token-cookie.shared.ts @@ -0,0 +1,32 @@ +/** + * Fragmentación del JWT access_token en varias cookies cuando supera el límite ~4KB del navegador. + * La lógica de fetch / Bearer no cambia: solo lectura/escritura de cookies. + */ + +/** Por debajo de esto se usa una sola cookie `access_token` (compatibilidad). */ +export const ACCESS_TOKEN_MAX_SINGLE = 2800; + +export const ACCESS_TOKEN_CHUNK_SIZE = 2800; + +/** Número de fragmentos; si existe, el token está en access_token_0..access_token_{n-1}. */ +export const ACCESS_TOKEN_CHUNK_COUNT = 'access_token_chunks'; + +export const accessTokenChunkName = (index: number) => `access_token_${index}`; + +export type AccessTokenSplit = + | { kind: 'single'; value: string } + | { kind: 'chunks'; parts: string[] }; + +export function splitAccessTokenForCookies(token: string): AccessTokenSplit { + if (token.length <= ACCESS_TOKEN_MAX_SINGLE) { + return { kind: 'single', value: token }; + } + const parts: string[] = []; + for (let i = 0; i < token.length; i += ACCESS_TOKEN_CHUNK_SIZE) { + parts.push(token.slice(i, i + ACCESS_TOKEN_CHUNK_SIZE)); + } + return { kind: 'chunks', parts }; +} + +/** Máximo de fragmentos soportados (JWT muy grande). */ +export const ACCESS_TOKEN_MAX_CHUNKS = 32; diff --git a/frontend/src/lib/actions/portal.ts b/frontend/src/lib/actions/portal.ts new file mode 100644 index 0000000..94f267d --- /dev/null +++ b/frontend/src/lib/actions/portal.ts @@ -0,0 +1,33 @@ +/** + * Svelte action that teleports a DOM node to a target element outside the + * current component tree. This ensures the node is not affected by focus + * traps, overlays, or event interceptors (e.g. Radix DismissibleLayer) that + * are scoped to a parent Dialog/Sheet portal. + * + * Usage: + *
...
→ appended to + *
...
→ appended to #target + */ +export function portal(node: HTMLElement, target: HTMLElement | string = 'body') { + function mount() { + const targetEl = + typeof target === 'string' + ? (document.querySelector(target) as HTMLElement | null) + : target; + + if (!targetEl) return; + targetEl.appendChild(node); + } + + mount(); + + return { + update(newTarget: HTMLElement | string) { + target = newTarget; + mount(); + }, + destroy() { + node.remove(); + } + }; +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..fc0686f --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,843 @@ +/** + * Cliente API para comunicación con el backend + */ +import { getToken } from './auth'; +import { browser } from '$app/environment'; +import { toast } from 'svelte-sonner'; +import { clearAccessTokenOnDocument, setAccessTokenOnDocument } from '$lib/access-token-cookie-browser'; + +/** Base URL absoluta para fetch; corrige `http:host` sin `//` y añade `http://` si no hay esquema. */ +function normalizeAbsoluteApiBaseUrl(raw: string): string { + let s = (raw ?? '').trim().replace(/\/+$/, ''); + if (!s) return ''; + if (s.startsWith('http:') && !s.startsWith('http://')) { + s = 'http://' + s.slice('http:'.length).replace(/^\/+/, ''); + } + if (s.startsWith('https:') && !s.startsWith('https://')) { + s = 'https://' + s.slice('https:'.length).replace(/^\/+/, ''); + } + if (s.startsWith('/')) return s; + if (/^https?:\/\//i.test(s)) return s; + return `http://${s.replace(/^\/+/, '')}`; +} + +const API_BASE_URL = normalizeAbsoluteApiBaseUrl(String(import.meta.env.VITE_API_URL ?? '')); + +export interface ApiResponse { + data?: T; + error?: string; + validationErrors?: Array<{ + field: string; + message: string; + code?: string; + solution?: string[]; + value?: any; + }>; + status: number; +} + +/** Reemplaza referencias técnicas `line[n]` por texto más claro para el usuario. */ +export function humanizeLineReferences(text: string): string { + return text.replace(/\bline\[(\d+)\]/gi, 'partida $1'); +} + +function humanizeFieldPath(field: string): string { + const rawField = (field || '').trim(); + if (!rawField) return 'campo'; + + const lineMatch = rawField.match(/^line\[(\d+)\]\.(.+)$/i); + const fieldPath = lineMatch?.[2] || rawField; + const label = fieldPath + .replace(/^body\./i, '') + .replace(/\./g, ' → ') + .replace(/_/g, ' '); + + if (lineMatch) { + return `Partida ${lineMatch[1]} - ${label}`; + } + + return label; +} + +function humanizeValidationMessage(message: string): string { + const rawMessage = (message || '').trim(); + if (!rawMessage) return 'error de validación'; + + return rawMessage + .replace(/\b(field required|is required)\b/gi, 'es obligatorio') + .replace(/\b(value is not a valid decimal)\b/gi, 'debe ser un número válido') + .replace(/\b(value is not a valid integer)\b/gi, 'debe ser un número entero válido'); +} + +function formatValidationHint(field: string, message: string, code?: string): string { + const fieldLabel = humanizeFieldPath(field); + const normalizedMessage = humanizeValidationMessage(message); + + if (code === 'REQUIRED' || code === 'REQUIRED_FIELD' || /es obligatorio|es requerido/i.test(normalizedMessage)) { + return `Completa ${fieldLabel}.`; + } + + if (code === 'AMERICAN_FRACTION_NOT_FOUND') { + return 'La fracción americana seleccionada no existe. Elige una opción del catálogo.'; + } + + if (code === 'UNIT_OF_MEASURE_NOT_FOUND') { + return 'La unidad de medida seleccionada no existe. Elige una opción del catálogo.'; + } + + if (code === 'ORIGIN_COUNTRY_NOT_FOUND') { + return 'El país de origen seleccionado no existe. Elige una opción del catálogo.'; + } + + if (code === 'CLASS_NOT_FOUND') { + return 'La clase seleccionada no existe. Elige una opción del catálogo.'; + } + + if (code === 'FRACTION_TYPE_INVALID') { + return 'Selecciona un tipo de tarifa válido.'; + } + + return normalizedMessage; +} + +/** + * Título y descripción listos para toasts / alertas a partir de ApiResponse. + * Prioriza los mensajes que ya envía el backend y evita duplicar rutas técnicas. + */ +export function friendlyApiErrorParts(res: ApiResponse): { title: string; description: string } { + const validationErrors = res.validationErrors; + if (validationErrors?.length) { + const blocks = validationErrors.map((e) => { + const base = formatValidationHint(e.field || '', e.message || '', e.code); + const hints = e.solution?.filter(Boolean).length + ? '\n' + e.solution!.map((s) => `• ${humanizeLineReferences(s)}`).join('\n') + : ''; + return base + hints; + }); + const description = blocks.join('\n\n').trim(); + const rawTitle = (res.error || '').trim(); + const title = + rawTitle && + !rawTitle.startsWith('Error de validación') && + rawTitle !== 'Error de validación' + ? rawTitle + : 'Revisa los datos de la partida'; + return { title, description: description || rawTitle || 'Corrige los datos e intenta de nuevo.' }; + } + + if (res.error) { + const err = humanizeValidationMessage(humanizeLineReferences(res.error.trim())); + if (err.startsWith('Error de validación:')) { + return { + title: 'Revisa los datos ingresados', + description: err.replace(/^Error de validación:\s*/i, '').trim() || err + }; + } + return { title: 'No se pudo completar la acción', description: err }; + } + + return { + title: 'Error', + description: 'Ocurrió un error inesperado. Intenta de nuevo o contacta a soporte si continúa.' + }; +} + +let isRefreshing = false; +let refreshSubscribers: ((token: string) => void)[] = []; + +/** + * Agrega una petición a la cola de espera mientras se refresca el token + */ +function subscribeTokenRefresh(callback: (token: string) => void) { + refreshSubscribers.push(callback); +} + +/** + * Notifica a todas las peticiones en espera que el token se ha refrescado + */ +function onTokenRefreshed(token: string) { + refreshSubscribers.forEach((callback) => callback(token)); + refreshSubscribers = []; +} + +/** + * Refresca el token silenciosamente usando el endpoint server-side. + * + * El servidor lee el refresh_token desde la cookie HttpOnly, + * llama a Keycloak, actualiza las cookies y devuelve el nuevo access_token. + * El refresh_token NUNCA es leído por este código JavaScript. + */ +async function refreshToken(): Promise { + if (!browser) return null; + + try { + const response = await fetch('/api-sveltekit/auth/silent-refresh', { + method: 'POST', + credentials: 'include', // Envía cookies HttpOnly automáticamente + headers: { 'Content-Type': 'application/json' } + }); + + if (!response.ok) { + console.error('❌ [API] Silent refresh falló, status:', response.status); + clearAccessTokenOnDocument(); + const hubBase = (import.meta.env.VITE_HUB_URL || 'https://workspace.aduanasoft.com').replace(/\/+$/, ''); + setTimeout(() => { + window.location.href = `${hubBase}/login?return_to=${encodeURIComponent(window.location.origin + '/login?sso_verified=1')}`; + }, 1500); + return null; + } + + const data = await response.json() as { access_token?: string }; + + if (data.access_token) { + setAccessTokenOnDocument(data.access_token); + + // Actualizar authStore en memoria + try { + const { authStore } = await import('./auth'); + authStore.setToken(data.access_token); + } catch {} + + return data.access_token; + } + + return null; + } catch (error) { + console.error('❌ [API] Error en silent refresh:', error); + return null; + } +} + +/** + * Construye los headers de autenticación (Bearer + X-Tenant-Override SSO multi-tenant). + * Compartido por fetchApi y fetchBlob para garantizar trato uniforme. + */ +function buildAuthHeaders(baseHeaders: Record = {}): Record { + const headers: Record = { ...baseHeaders }; + const token = getToken(); + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + // sso_tenant_pub es una cookie no-HttpOnly que el servidor setea junto con sso_tenant_id. + if (browser) { + const tenantPub = document.cookie + .split('; ') + .find((c) => c.startsWith('sso_tenant_pub=')) + ?.split('=')[1]; + if (tenantPub) { + headers['X-Tenant-Override'] = tenantPub; + } + + // active_system (SCAF/SCAII): cookie no-HttpOnly → header explícito para el backend. + const activeSystem = document.cookie + .split('; ') + .find((c) => c.startsWith('active_system=')) + ?.split('=')[1]; + if (activeSystem) { + headers['X-Active-System'] = activeSystem; + } + } + + return headers; +} + +/** + * Realiza una petición al API con manejo automático de refresh token + */ +async function fetchApi( + endpoint: string, + options: RequestInit = {}, + retryCount = 0 +): Promise> { + // Si ya estamos refrescando el token, esperar + if (isRefreshing && retryCount === 0) { + return new Promise((resolve) => { + subscribeTokenRefresh((newToken) => { + resolve(fetchApi(endpoint, options, 1)); + }); + }); + } + + const token = getToken(); + + if (!token && !endpoint.includes('/auth/login')) { + console.warn('⚠️ [API] No hay token disponible para', endpoint); + } + + const baseHeaders: Record = { + ...((options.headers as Record) || {}) + }; + + // Only set Content-Type to application/json if not already set and body is not FormData + if (!baseHeaders['Content-Type'] && !(options.body instanceof FormData)) { + baseHeaders['Content-Type'] = 'application/json'; + } + + const headers = buildAuthHeaders(baseHeaders); + + try { + const response = await fetch(`${API_BASE_URL}${endpoint}`, { + ...options, + headers, + credentials: 'include' // Importante: envía cookies con cada request + }); + + // 403 = permisos, no autenticación: nunca intentar refresh. + if (response.status === 403 && !endpoint.includes('/auth/refresh') && retryCount === 0) { + if (browser) { + toast.error('No tienes permisos para realizar esta acción', { + duration: 4000, + description: 'Contacta a tu administrador si crees que esto es un error' + }); + } + const data = await response.json(); + return { + error: data.detail || 'No tienes permisos para realizar esta acción', + status: 403 + }; + } + + // 402 = licencia inválida/expirada: no intentar refresh. + if (response.status === 402 && !endpoint.includes('/auth/refresh') && retryCount === 0) { + const data = await response.json().catch(() => ({})); + return { + error: data.message || data.detail || 'Licencia inválida o expirada', + status: 402 + }; + } + + // Solo 401 dispara silent refresh. + if (response.status === 401 && !endpoint.includes('/auth/refresh') && retryCount === 0) { + // Si es 401, intentar refrescar el token + isRefreshing = true; + + try { + const newToken = await refreshToken(); + + if (newToken) { + // Token refrescado exitosamente + onTokenRefreshed(newToken); + isRefreshing = false; + // Reintentar la petición original con el nuevo token + return await fetchApi(endpoint, options, 1); + } else { + console.error('❌ [API] No se pudo refrescar el token'); + isRefreshing = false; + // Retornar error 401 para que la capa superior lo maneje + return { + error: 'Sesión expirada. Por favor, inicia sesión nuevamente.', + status: 401 + }; + } + } catch (refreshError) { + console.error('❌ [API] Error al refrescar:', refreshError); + isRefreshing = false; + return { + error: 'Error al refrescar la sesión', + status: 401 + }; + } + } + + // Manejar respuestas sin contenido (204 No Content) + if (response.status === 204) { + return { + data: null as T, + status: response.status + }; + } + + const data = await response.json(); + + if (!response.ok) { + // Manejo especial para errores 422 (validation error) + if (response.status === 422) { + // HTTPException(detail={ message, errors }) — catálogo / CSV parity + const det = data.detail || (typeof data.message === 'object' ? data.message : null); + if ( + det && + typeof det === "object" && + !Array.isArray(det) && + Array.isArray((det as { errors?: unknown }).errors) + ) { + const d = det as { + message?: string; + errors: Array<{ col?: string; msg?: string; field?: string; message?: string }>; + }; + + // Mapping for catalog column names to DTO field names + const colToField: Record = { + "CLAVE TRANSPORTISTA": "transporter_key", + NOMBRE: "name", + "NOMBRE CORTO": "short_name", + RESPONSABLE: "responsible", + RFC: "rfc", + CALLES: "streets", + "CODIGO POSTAL": "postal_code", + CIUDAD: "city", + ESTADO: "state", + PAIS: "country", + "CODIGO CARGADOR": "loader_code", + "CODIGO CAAT": "caat_code", + "CODIGO TRANS": "transport_code", + "TIPO INTERFASE TRANS": "transport_interface_type", + "SERVIDOR FTP": "ftp_server", + "USUARIO FTP": "ftp_user", + "CLAVE ACCESO FTP": "ftp_password", + "DIRECTORIO FTP": "ftp_directory", + // Vehicles + CLAVE: "vehicle_key", + "CLAVE ACE": "ace_vehicle_key", // Fallback for vehicles + "CLAVE TRANSPORTE": "transporter_key", + VIN: "series", + "TIPO TRANSPORTE": "transport_type", + "CODIGO DE ENTIDAD": "entity_code", + TRANSPONDEDOR: "transponder_number", + "NUMERO DOT": "dot_number", + PLACAS: "plate_number", + PRECINTO: "seal", + "EMPRESA ASEGURADORA": "insurance_company_name", + "NUM. ASEGURADORA": "insurance_number", + "MONTO ASEGURADO": "insurance_amount", + "FECHA DE ASEGURADORA": "insurance_date", + // Trailers + "NUMERO TRAILER": "trailer_number", + "TIPO TRAILER": "trailer_type_key", + "CODIGO ENTIDAD": "entity_code", + "CLAVE CONTENEDOR": "container_key" + }; + + const normalizedErrors = d.errors.map((err) => ({ + field: err.field || (err.col ? colToField[err.col] || err.col : ""), + message: err.message || err.msg || "Error de validación" + })); + + return { + error: d.message || (typeof data.message === 'string' ? data.message : 'Error de validación'), + validationErrors: normalizedErrors, + status: response.status + }; + } + // Errores de validación personalizados (con array errors en raíz) + if (data.errors && Array.isArray(data.errors)) { + return { + error: data.message || 'Error de validación', + validationErrors: data.errors as NonNullable, + status: response.status + }; + } + // Errores de validación de FastAPI (con detail) + else if (data.detail) { + let errorMessage = 'Error de validación: '; + const vErrors: NonNullable = []; + + // FastAPI devuelve errores de validación en data.detail como array + if (Array.isArray(data.detail)) { + data.detail.forEach((err: any) => { + const fieldPath = err.loc ? err.loc.filter((l: any) => l !== 'body').join('.') : 'campo'; + const msg = humanizeValidationMessage(err.msg || 'error de validación'); + + vErrors.push({ + field: err.loc ? String(err.loc[err.loc.length - 1]) : 'campo', + message: msg + }); + }); + + errorMessage += data.detail.map((err: any) => { + const field = err.loc ? err.loc.join('.') : 'campo desconocido'; + return `${field}: ${err.msg}`; + }).join(', '); + } else if (typeof data.detail === 'string') { + errorMessage = data.detail; + } else { + errorMessage += JSON.stringify(data.detail); + } + + return { + error: errorMessage, + validationErrors: vErrors.length ? vErrors : undefined, + status: response.status + }; + } + } + + return { + error: data.message || (typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail)) || 'Error en la petición', + status: response.status + }; + } + + return { + data, + status: response.status + }; + } catch (error) { + console.error(`❌ [API] Error de conexión en ${endpoint}:`, error); + return { + error: 'Error de conexión con el servidor', + status: 0 + }; + } +} + +/** Opciones para subidas CSV (FormData) con progreso de red. */ +export type CsvFormDataUploadOptions = { + onUploadProgress?: (e: { loaded: number; total: number }) => void; +}; + +/** + * POST multipart/form-data con XMLHttpRequest para exponer progreso de subida. + * Misma semántica de auth/401/403/422 que fetchApi. + */ +async function fetchApiFormDataPost( + endpoint: string, + formData: FormData, + opts: CsvFormDataUploadOptions & { retryCount?: number } = {} +): Promise> { + const retryCount = opts.retryCount ?? 0; + + if (isRefreshing && retryCount === 0) { + return new Promise((resolve) => { + subscribeTokenRefresh(() => { + resolve(fetchApiFormDataPost(endpoint, formData, { ...opts, retryCount: 1 })); + }); + }); + } + + return new Promise((resolve) => { + const token = getToken(); + const xhr = new XMLHttpRequest(); + xhr.open('POST', `${API_BASE_URL}${endpoint}`); + xhr.withCredentials = true; + if (token) { + xhr.setRequestHeader('Authorization', `Bearer ${token}`); + } + + xhr.upload.onprogress = (ev) => { + if (!opts.onUploadProgress) return; + if (ev.lengthComputable) { + opts.onUploadProgress({ loaded: ev.loaded, total: ev.total }); + } else { + opts.onUploadProgress({ loaded: ev.loaded, total: 0 }); + } + }; + + xhr.onload = () => { + void (async () => { + const status = xhr.status; + let data: any = null; + if (xhr.responseText) { + try { + data = JSON.parse(xhr.responseText) as any; + } catch { + data = null; + } + } + + if ((status === 401 || status === 403) && !endpoint.includes('/auth/refresh') && retryCount === 0) { + if (status === 403) { + if (browser) { + toast.error('No tienes permisos para realizar esta acción', { + duration: 4000, + description: 'Contacta a tu administrador si crees que esto es un error' + }); + } + resolve({ + error: data?.detail || 'No tienes permisos para realizar esta acción', + status: 403 + }); + return; + } + + isRefreshing = true; + try { + const newToken = await refreshToken(); + if (newToken) { + onTokenRefreshed(newToken); + isRefreshing = false; + resolve(await fetchApiFormDataPost(endpoint, formData, { ...opts, retryCount: 1 })); + } else { + console.error('❌ [API] No se pudo refrescar el token'); + isRefreshing = false; + resolve({ + error: 'Sesión expirada. Por favor, inicia sesión nuevamente.', + status: 401 + }); + } + } catch (refreshError) { + console.error('❌ [API] Error al refrescar:', refreshError); + isRefreshing = false; + resolve({ + error: 'Error al refrescar la sesión', + status: 401 + }); + } + return; + } + + if (status === 204) { + resolve({ + data: null as T, + status + }); + return; + } + + if (status === 0) { + resolve({ + error: 'Error de conexión con el servidor', + status: 0 + }); + return; + } + + if (status < 200 || status >= 300) { + if (status === 422 && data) { + if (data.errors && Array.isArray(data.errors)) { + resolve({ + error: data.message || 'Error de validación', + validationErrors: data.errors as NonNullable, + status: 422 + }); + return; + } + if (data.detail) { + let errorMessage = 'Error de validación: '; + if (Array.isArray(data.detail)) { + const errors = data.detail + .map((err: any) => { + const field = err.loc ? err.loc.filter((loc: string) => loc !== 'body').join('.') : 'campo desconocido'; + return `${humanizeFieldPath(field)}: ${humanizeValidationMessage(err.msg || 'error de validación')}`; + }) + .join(', '); + errorMessage += errors; + } else if (typeof data.detail === 'string') { + errorMessage = data.detail; + } else { + errorMessage += JSON.stringify(data.detail); + } + resolve({ + error: errorMessage, + status: 422 + }); + return; + } + } + resolve({ + error: + data?.message || + (typeof data?.detail === 'string' ? data.detail : JSON.stringify(data?.detail)) || + 'Error en la petición', + status + }); + return; + } + + if (data === null && xhr.responseText) { + resolve({ + error: 'Respuesta inválida del servidor', + status + }); + return; + } + + resolve({ + data, + status + }); + })(); + }; + + xhr.onerror = () => { + resolve({ + error: 'Error de conexión con el servidor', + status: 0 + }); + }; + + try { + xhr.send(formData); + } catch (error) { + console.error(`❌ [API] Error al enviar ${endpoint}:`, error); + resolve({ + error: 'Error de conexión con el servidor', + status: 0 + }); + } + }); +} + +/** + * Convierte cuerpos de error (JSON o texto) en un mensaje legible para toasts/UX. + * Evita mostrar JSON crudo p. ej. `{"error":"HTTP_ERROR","message":"..."}`. + */ +function messageFromBlobErrorResponse(text: string, status: number): string { + const raw = (text || '').trim(); + if (!raw) { + return status === 404 + ? 'No se encontró el recurso. Prueba otro rango o vuelve a intentar.' + : `Error ${status} al descargar el archivo.`; + } + try { + const data = JSON.parse(raw) as Record; + if (typeof data.message === 'string' && data.message.trim()) { + return data.message.trim(); + } + const d = data.detail; + if (typeof d === 'string' && d.trim()) { + return d.trim(); + } + if (Array.isArray(d) && d[0] && typeof (d[0] as { msg?: string }).msg === 'string') { + return String((d[0] as { msg: string }).msg).trim(); + } + } catch { + // no es JSON: usar texto plano si es corto y legible + } + if (raw.length < 500 && !raw.startsWith('{')) { + return raw; + } + if (raw.startsWith('{')) { + return status === 404 + ? 'No se encontró información para exportar. Prueba otras fechas o amplía el rango.' + : `Error ${status} al descargar el archivo.`; + } + return raw; +} + +async function fetchBlob( + endpoint: string, + options: RequestInit = {}, + retryCount = 0 +): Promise { + // Si ya estamos refrescando el token, esperar a que termine antes de pegar. + if (isRefreshing && retryCount === 0) { + return new Promise((resolve, reject) => { + subscribeTokenRefresh(() => { + fetchBlob(endpoint, options, 1).then(resolve).catch(reject); + }); + }); + } + + const headers = buildAuthHeaders((options.headers as Record) || {}); + + const response = await fetch(`${API_BASE_URL}${endpoint}`, { + ...options, + headers, + credentials: 'include' + }); + + // 401: intentar silent refresh y reintentar una vez (mismo flujo que fetchApi). + if (response.status === 401 && !endpoint.includes('/auth/refresh') && retryCount === 0) { + isRefreshing = true; + try { + const newToken = await refreshToken(); + if (newToken) { + onTokenRefreshed(newToken); + isRefreshing = false; + return await fetchBlob(endpoint, options, 1); + } + isRefreshing = false; + throw new Error('Sesión expirada. Por favor, inicia sesión nuevamente.'); + } catch (refreshError) { + isRefreshing = false; + if (refreshError instanceof Error) throw refreshError; + throw new Error('Error al refrescar la sesión'); + } + } + + // 403: notificar permisos de manera consistente con fetchApi. + if (response.status === 403) { + if (browser) { + toast.error('No tienes permisos para realizar esta acción', { + duration: 4000, + description: 'Contacta a tu administrador si crees que esto es un error' + }); + } + const text = await response.text().catch(() => ''); + throw new Error(messageFromBlobErrorResponse(text, response.status)); + } + + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(messageFromBlobErrorResponse(text, response.status)); + } + return await response.blob(); +} + +// Métodos HTTP +export const api = { + get: (endpoint: string) => fetchApi(endpoint, { method: 'GET' }), + getBlob: (endpoint: string) => fetchBlob(endpoint, { method: 'GET' }), + postBlob: (endpoint: string, body: any) => + fetchBlob(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }), + + post: (endpoint: string, body: any, options: RequestInit = {}) => + fetchApi(endpoint, { + method: 'POST', + body: JSON.stringify(body), + ...options + }), + + put: (endpoint: string, body: any, options: RequestInit = {}) => + fetchApi(endpoint, { + method: 'PUT', + body: JSON.stringify(body), + ...options + }), + + patch: (endpoint: string, body: any, options: RequestInit = {}) => + fetchApi(endpoint, { + method: 'PATCH', + body: JSON.stringify(body), + ...options + }), + + delete: (endpoint: string, options: RequestInit = {}) => fetchApi(endpoint, { method: 'DELETE', ...options }), + + auth: { + login: (credentials: { username: string; password: string; tenant_slug: string }) => + api.post('/v1/auth/login/', credentials), + refresh: (refreshToken: string) => + api.post('/v1/auth/refresh/', { refresh_token: refreshToken }), + logout: (data: { refresh_token: string, username?: string }) => api.post('/v1/auth/logout', data, { keepalive: true }), + me: () => api.get('/v1/auth/me/'), + health: () => api.get('/health'), + register: (data: { + username: string; + email: string; + password: string; + first_name: string; + last_name: string; + tenant_slug: string; + invite_token?: string; + }) => api.post('/v1/auth/register', data), + }, + + tenants: { + list: (page = 1, pageSize = 50) => + api.get(`/v1/tenants/?page=${page}&page_size=${pageSize}`), + get: (id: number) => api.get(`/v1/tenants/${id}/`), + create: (data: any) => api.post('/v1/tenants/', data), + update: (id: number, data: any) => api.put(`/v1/tenants/${id}/`, data) + }, + + licenses: { + get: (tenantId: number) => api.get(`/v1/licenses/tenant/${tenantId}/`), + myLicense: () => api.get('/v1/licenses/my-license/'), + usage: (tenantId: number) => api.get(`/v1/licenses/usage/${tenantId}/`), + validate: (tenantId: number) => api.get(`/v1/licenses/validate/${tenantId}/`) + }, + + // Agrega aquí los endpoints específicos de tu proyecto. + + // Implementa aquí tus endpoints de importación CSV si los necesitas. + + // Generic request for custom needs (like file uploads) + request: (endpoint: string, options: RequestInit = {}) => fetchApi(endpoint, options) +}; diff --git a/frontend/src/lib/api/dashboard/admin/index.ts b/frontend/src/lib/api/dashboard/admin/index.ts new file mode 100644 index 0000000..4bc66b7 --- /dev/null +++ b/frontend/src/lib/api/dashboard/admin/index.ts @@ -0,0 +1,8 @@ +/** + * Exportaciones centralizadas de APIs de administración + */ + +export * from './permissions'; +export * from './roles'; +export * from './role-permissions'; +export * from './user-roles'; diff --git a/frontend/src/lib/api/dashboard/admin/permissions.ts b/frontend/src/lib/api/dashboard/admin/permissions.ts new file mode 100644 index 0000000..e6d2ef0 --- /dev/null +++ b/frontend/src/lib/api/dashboard/admin/permissions.ts @@ -0,0 +1,110 @@ +/** + * API para gestión de permisos del sistema + */ + +import { api } from '$lib/api'; + +export interface Permission { + id: number; + code: string; + description?: string; + module: string; + action: string; + is_active: boolean; + created_at?: string; + updated_at?: string; +} + +export interface CreatePermissionData { + code?: string; + description?: string; + module: string; + action: string; + is_active?: boolean; +} + +export interface UpdatePermissionData { + code?: string; + description?: string; + module?: string; + action?: string; + is_active?: boolean; +} + +export interface PermissionListResponse { + items: Permission[]; + total: number; + page: number; + page_size: number; +} + +export const permissionsAPI = { + /** + * Listar permisos con filtros + */ + async list(params?: { + page?: number; + page_size?: number; + module?: string; + action?: string; + is_active?: boolean; + search?: string; + }): Promise { + const queryParams = new URLSearchParams(); + if (params?.page) queryParams.set('page', params.page.toString()); + if (params?.page_size) queryParams.set('page_size', params.page_size.toString()); + if (params?.module) queryParams.set('module', params.module); + if (params?.action) queryParams.set('action', params.action); + if (params?.search) queryParams.set('search', params.search); + const query = queryParams.toString(); + const response = await api.get(`/v1/core/permissions/${query ? '?' + query : ''}`); + return response.data; + }, + + /** + * Obtener un permiso por ID + */ + async getById(id: number): Promise { + const response = await api.get(`/v1/core/permissions/${id}/`); + return response.data; + }, + + /** + * Crear un nuevo permiso + */ + async create(data: CreatePermissionData): Promise { + const response = await api.post('/v1/core/permissions/', data); + return response.data; + }, + + /** + * Actualizar un permiso + */ + async update(id: number, data: UpdatePermissionData): Promise { + const response = await api.put(`/v1/core/permissions/${id}/`, data); + return response.data; + }, + + /** + * Eliminar un permiso + */ + async delete(id: number): Promise { + await api.delete(`/v1/core/permissions/${id}/`); + }, + + /** + * Obtener módulos únicos + */ + async getModules(): Promise { + const response = await api.get('/v1/core/permissions/modules/'); + return response.data; + }, + + /** + * Obtener acciones únicas + */ + async getActions(): Promise { + const response = await api.get('/v1/core/permissions/actions/'); + return response.data; + } +}; diff --git a/frontend/src/lib/api/dashboard/admin/role-permissions.ts b/frontend/src/lib/api/dashboard/admin/role-permissions.ts new file mode 100644 index 0000000..208ed61 --- /dev/null +++ b/frontend/src/lib/api/dashboard/admin/role-permissions.ts @@ -0,0 +1,76 @@ +/** + * API para gestión de permisos de roles + */ + +import { api } from '$lib/api'; + +export interface RolePermission { + id: number; + company_role_id: number; + permission_id: number; + granted_at?: string; + granted_by?: number; + tenant_id: number; + permission?: { + id: number; + code: string; + module: string; + action: string; + description?: string; + is_active: boolean; + }; +} + +export interface AssignPermissionData { + permission_id: number; +} + +export interface RolePermissionsResponse { + role_id: number; + permissions: RolePermission[]; + total: number; +} + +export const rolePermissionsAPI = { + /** + * Listar todos los permisos asignados a un rol + */ + async listByRole(roleId: number, companyId: number): Promise { + const response = await api.get(`/v1/core/permissions/roles/${roleId}/permissions?company_id=${companyId}`); + return response.data; + }, + + /** + * Asignar un permiso a un rol + */ + async assign( + roleId: number, + companyId: number, + data: AssignPermissionData + ): Promise { + const response = await api.post(`/v1/core/permissions/roles/${roleId}/permissions?company_id=${companyId}`, data); + return response.data; + }, + + /** + * Remover un permiso de un rol + */ + async remove(roleId: number, permissionId: number, companyId: number): Promise { + await api.delete(`/v1/core/permissions/roles/${roleId}/permissions/${permissionId}?company_id=${companyId}`); + }, + + /** + * Asignar múltiples permisos a un rol + */ + async assignMultiple( + roleId: number, + companyId: number, + permissionIds: number[] + ): Promise { + const response = await api.post( + `/v1/core/permissions/roles/${roleId}/permissions/batch?company_id=${companyId}`, + { permission_ids: permissionIds } + ); + return response.data; + } +}; diff --git a/frontend/src/lib/api/dashboard/admin/roles.ts b/frontend/src/lib/api/dashboard/admin/roles.ts new file mode 100644 index 0000000..10e34cc --- /dev/null +++ b/frontend/src/lib/api/dashboard/admin/roles.ts @@ -0,0 +1,89 @@ +/** + * API para gestión de roles por compañía + */ + +import { api, type ApiResponse } from '$lib/api'; + +export interface CompanyRole { + id: number; + name: string; + code: string; + description?: string; + is_active: boolean; + company_id: number; + tenant_id: number; + created_at?: string; + updated_at?: string; +} + +export interface CreateRoleData { + name: string; + code: string; + description?: string; + is_active?: boolean; +} + +export interface UpdateRoleData { + name?: string; + code?: string; + description?: string; + is_active?: boolean; +} + +export interface RoleListResponse { + items: CompanyRole[]; + total: number; + page: number; + page_size: number; +} + +export const rolesAPI = { + /** + * Listar roles con filtros + */ + async list( + companyId: number, + params?: { + page?: number; + page_size?: number; + is_active?: boolean; + search?: string; + } + ): Promise> { + const queryParams = new URLSearchParams(); + queryParams.set('company_id', companyId.toString()); + if (params?.page) queryParams.set('page', params.page.toString()); + if (params?.page_size) queryParams.set('page_size', params.page_size.toString()); + if (params?.is_active !== undefined) queryParams.set('is_active', params.is_active.toString()); + if (params?.search) queryParams.set('search', params.search); + return api.get(`/v1/core/permissions/roles?${queryParams.toString()}`); + }, + + /** + * Obtener un rol por ID + */ + async getById(id: number, companyId: number): Promise> { + return api.get(`/v1/core/permissions/roles/${id}?company_id=${companyId}`); + }, + + /** + * Crear un nuevo rol + */ + async create(companyId: number, data: CreateRoleData): Promise> { + return api.post(`/v1/core/permissions/roles?company_id=${companyId}`, data); + }, + + /** + * Actualizar un rol + */ + async update(id: number, companyId: number, data: UpdateRoleData): Promise> { + return api.patch(`/v1/core/permissions/roles/${id}?company_id=${companyId}`, data); + }, + + /** + * Eliminar un rol + */ + async delete(id: number, companyId: number): Promise> { + return api.delete(`/v1/core/permissions/roles/${id}?company_id=${companyId}`); + } +}; diff --git a/frontend/src/lib/api/dashboard/admin/user-permissions.ts b/frontend/src/lib/api/dashboard/admin/user-permissions.ts new file mode 100644 index 0000000..079063b --- /dev/null +++ b/frontend/src/lib/api/dashboard/admin/user-permissions.ts @@ -0,0 +1,108 @@ +/** + * API para gestión de permisos individuales de usuario + */ + +import { api } from '$lib/api'; +import type { Permission } from './permissions'; + +export interface UserPermission { + id: number; + user_id: string; + permission_id: number; + company_id: number; + tenant_id: number; + is_granted: boolean; + is_active: boolean; + assigned_by?: string; + expires_at?: string; + created_at?: string; + updated_at?: string; + permission?: Permission; +} + +export interface AssignUserPermissionData { + user_id: string; + permission_id: number; + is_granted?: boolean; + expires_at?: string; +} + +export interface UserPermissionsListResponse { + items: UserPermission[]; + total: number; +} + +export interface EffectiveUserPermissions { + user_id: string; + company_id: number; + role_permissions: Permission[]; + granted_permissions: Permission[]; + revoked_permissions: Permission[]; + effective_permissions: Permission[]; +} + +export const userPermissionsAPI = { + /** + * Obtener permisos individuales de un usuario + */ + async getIndividual(userId: string, companyId: number): Promise { + const response = await api.get( + `/v1/core/permissions/users/${userId}/permissions?company_id=${companyId}` + ); + return response.data; + }, + + /** + * Obtener permisos efectivos de un usuario (roles + individuales - revocados) + */ + async getEffective(userId: string, companyId: number): Promise { + const response = await api.get( + `/v1/core/permissions/users/${userId}/permissions/effective?company_id=${companyId}` + ); + return response.data; + }, + + /** + * Asignar un permiso individual a un usuario + */ + async assign( + userId: string, + companyId: number, + data: Omit + ): Promise { + const response = await api.post( + `/v1/core/permissions/users/${userId}/permissions?company_id=${companyId}`, + data + ); + return response.data; + }, + + /** + * Conceder un permiso extra a un usuario + */ + async grant(userId: string, companyId: number, permissionId: number): Promise { + return this.assign(userId, companyId, { + permission_id: permissionId, + is_granted: true + }); + }, + + /** + * Revocar un permiso específico (aunque venga del rol) + */ + async revoke(userId: string, companyId: number, permissionId: number): Promise { + return this.assign(userId, companyId, { + permission_id: permissionId, + is_granted: false + }); + }, + + /** + * Eliminar un permiso individual + */ + async remove(userId: string, companyId: number, permissionId: number): Promise { + await api.delete( + `/v1/core/permissions/users/${userId}/permissions/${permissionId}?company_id=${companyId}` + ); + } +}; diff --git a/frontend/src/lib/api/dashboard/admin/user-roles.ts b/frontend/src/lib/api/dashboard/admin/user-roles.ts new file mode 100644 index 0000000..c41742d --- /dev/null +++ b/frontend/src/lib/api/dashboard/admin/user-roles.ts @@ -0,0 +1,94 @@ +/** + * API para gestión de roles de usuarios + */ + +import { api } from '$lib/api'; + +export interface UserRole { + id: number; + user_id: string; + company_id: number; + company_role_id: number; + is_active: boolean; + created_at: string; + assigned_by?: string; + company_role?: { + id: number; + name: string; + code: string; + description?: string; + }; + user?: { + id: number; + username: string; + email?: string; + full_name?: string; + }; +} + +export interface AssignUserRoleData { + user_id: string; + company_role_id: number; +} + +export interface UserRolesResponse { + items: UserRole[]; + total: number; + page: number; + page_size: number; +} + +export const userRolesAPI = { + /** + * Listar todos los roles asignados a usuarios + */ + async list( + companyId: number, + params?: { + user_id?: string; + company_role_id?: number; + page?: number; + page_size?: number; + } + ): Promise { + const queryParams = new URLSearchParams(); + queryParams.set('company_id', companyId.toString()); + if (params?.user_id) queryParams.set('user_id', params.user_id); + if (params?.company_role_id) queryParams.set('company_role_id', params.company_role_id.toString()); + if (params?.page) queryParams.set('page', params.page.toString()); + if (params?.page_size) queryParams.set('page_size', params.page_size.toString()); + const response = await api.get(`/v1/core/permissions/user-roles?${queryParams.toString()}`); + return response.data; + }, + + /** + * Listar roles de un usuario específico + */ + async listByUser(userId: number, companyId: number): Promise { + const response = await api.get(`/v1/core/permissions/users/${userId}/roles?company_id=${companyId}`); + return response.data; + }, + + /** + * Listar usuarios con un rol específico + */ + async listByRole(roleId: number, companyId: number): Promise { + const response = await api.get(`/v1/core/permissions/roles/${roleId}/users?company_id=${companyId}`); + return response.data; + }, + + /** + * Asignar un rol a un usuario + */ + async assign(companyId: number, data: AssignUserRoleData): Promise { + const response = await api.post(`/v1/core/permissions/user-roles?company_id=${companyId}`, data); + return response.data; + }, + + /** + * Remover un rol de un usuario + */ + async remove(userRoleId: number, companyId: number): Promise { + await api.delete(`/v1/core/permissions/user-roles/${userRoleId}?company_id=${companyId}`); + } +}; diff --git a/frontend/src/lib/api/dashboard/index.ts b/frontend/src/lib/api/dashboard/index.ts new file mode 100644 index 0000000..226072d --- /dev/null +++ b/frontend/src/lib/api/dashboard/index.ts @@ -0,0 +1 @@ +export type { KPIMetric, ActivityItem, ChartDataPoint } from './types'; diff --git a/frontend/src/lib/api/dashboard/invite-codes.ts b/frontend/src/lib/api/dashboard/invite-codes.ts new file mode 100644 index 0000000..d06d317 --- /dev/null +++ b/frontend/src/lib/api/dashboard/invite-codes.ts @@ -0,0 +1,60 @@ +import { api } from '$lib/api'; + +export interface InviteCode { + id: number; + code: string; + tenant_slug: string; + company_id: number | null; + role: string; + max_uses: number | null; + uses_count: number; + expires_at: string | null; + is_active: boolean; + created_by: string; + created_at: string; +} + +export interface CreateInviteCodeRequest { + company_id?: number | null; + role: string; + max_uses?: number | null; + expires_at?: string | null; +} + +export interface ValidateInviteCodeResponse { + code: string; + tenant_slug: string; + company_id: number | null; + role: string; + remaining_uses: number | null; + expires_at: string | null; +} + +export const inviteCodesAPI = { + async list(companyId: number, includeInactive = false): Promise { + const params = new URLSearchParams({ company_id: companyId.toString() }); + if (includeInactive) params.set('include_inactive', 'true'); + const response = await api.get(`/v1/core/invite-codes?${params}`); + if (response.error) throw new Error(response.error); + return response.data!; + }, + + async create(data: CreateInviteCodeRequest): Promise { + const response = await api.post('/v1/core/invite-codes', data); + if (response.error) throw new Error(response.error); + return response.data!; + }, + + async revoke(code: string): Promise { + const response = await api.delete(`/v1/core/invite-codes/${code}`); + if (response.error) throw new Error(response.error); + }, + + async validate(code: string): Promise { + const response = await api.get( + `/v1/core/invite-codes/validate/${code}` + ); + if (response.error) throw new Error(response.error); + return response.data!; + } +}; diff --git a/frontend/src/lib/api/dashboard/types.ts b/frontend/src/lib/api/dashboard/types.ts new file mode 100644 index 0000000..574a74d --- /dev/null +++ b/frontend/src/lib/api/dashboard/types.ts @@ -0,0 +1,24 @@ +export interface KPIMetric { + label: string; + value: number; + previous_value?: number; + percentage_change?: number; + trend?: 'up' | 'down' | 'stable'; + unit?: string; +} + +export interface ActivityItem { + id: number; + type: string; + title: string; + description?: string; + timestamp: string; + status?: string; + icon?: string; +} + +export interface ChartDataPoint { + label: string; + value: number; + category?: string; +} diff --git a/frontend/src/lib/api/dashboard/users.ts b/frontend/src/lib/api/dashboard/users.ts new file mode 100644 index 0000000..5093e2e --- /dev/null +++ b/frontend/src/lib/api/dashboard/users.ts @@ -0,0 +1,196 @@ +/** + * Cliente API para gestión de usuarios + */ + +import { api } from '$lib/api'; + +export interface User { + id: string; + username: string; + email: string; + first_name: string; + last_name: string; + enabled: boolean; + email_verified: boolean; + created_timestamp?: number; + role?: string; +} + +export interface UserStats { + total_users: number; + active_users: number; + inactive_users: number; + max_users_allowed: number; + users_available: number; + usage_percentage: number; +} + +export interface UserListResponse { + users: User[]; + total: number; + page: number; + page_size: number; + total_pages: number; +} + +export interface CreateUserRequest { + email: string; + username: string; + first_name: string; + last_name: string; + password: string; + role?: string; + enabled?: boolean; + email_verified?: boolean; +} + +export interface UpdateUserRequest { + first_name?: string; + last_name?: string; + email?: string; + enabled?: boolean; + email_verified?: boolean; + role?: string; +} + +export interface ChangePasswordRequest { + password: string; + temporary?: boolean; +} + +export interface InviteUserRequest { + email: string; + company_id: number; + role_id: number; +} + +export interface InviteUserResponse { + id: number; + email: string; + role: string; + expires_at: string; + invite_url: string; + created_at: string; +} + +export const usersAPI = { + /** + * Obtiene estadísticas de usuarios del tenant + */ + async getStats(companyId: number): Promise { + const response = await api.get(`/v1/core/users/stats?company_id=${companyId}`); + if (response.error) { + throw new Error(response.error); + } + return response.data!; + }, + + /** + * Lista usuarios del tenant con paginación + */ + async list(companyId: number, params?: { + page?: number; + page_size?: number; + search?: string; + }): Promise { + const queryParams = new URLSearchParams(); + queryParams.set('company_id', companyId.toString()); + if (params?.page) queryParams.set('page', params.page.toString()); + if (params?.page_size) queryParams.set('page_size', params.page_size.toString()); + if (params?.search) queryParams.set('search', params.search); + + const endpoint = `/v1/core/users/?${queryParams}`; + const response = await api.get(endpoint); + if (response.error) { + throw new Error(response.error); + } + return response.data!; + }, + + /** + * Obtiene un usuario específico + */ + async get(userId: string, companyId: number): Promise { + const response = await api.get(`/v1/core/users/${userId}?company_id=${companyId}`); + if (response.error) { + throw new Error(response.error); + } + return response.data!; + }, + + /** + * Crea un nuevo usuario + */ + async create(data: CreateUserRequest, companyId: number): Promise { + const response = await api.post(`/v1/core/users/?company_id=${companyId}`, data); + if (response.error) { + throw new Error(response.error); + } + return response.data!; + }, + + /** + * Actualiza un usuario existente + */ + async update(userId: string, data: UpdateUserRequest, companyId: number): Promise { + const response = await api.put(`/v1/core/users/${userId}?company_id=${companyId}`, data); + if (response.error) { + throw new Error(response.error); + } + return response.data!; + }, + + /** + * Retorna en cuántos tenants está registrado el usuario. + */ + async getTenantCount(userId: string, companyId: number): Promise { + const response = await api.get<{ tenant_count: number }>( + `/v1/core/users/${userId}/tenant-count?company_id=${companyId}` + ); + if (response.error) { + throw new Error(response.error); + } + return response.data!.tenant_count; + }, + + /** + * Elimina un usuario + */ + async delete( + userId: string, + companyId: number, + softDelete: boolean = true, + scope: 'current' | 'all' = 'current' + ): Promise { + const queryParams = new URLSearchParams(); + queryParams.set('company_id', companyId.toString()); + queryParams.set('soft_delete', softDelete.toString()); + queryParams.set('scope', scope); + + const response = await api.delete(`/v1/core/users/${userId}?${queryParams}`); + if (response.error) { + throw new Error(response.error); + } + }, + + /** + * Cambia la contraseña de un usuario + */ + async changePassword(userId: string, data: ChangePasswordRequest, companyId: number): Promise { + const response = await api.post(`/v1/core/users/${userId}/change-password?company_id=${companyId}`, data); + if (response.error) { + throw new Error(response.error); + } + }, + + /** + * Genera un token de invitación y envía email al usuario + */ + async invite(data: InviteUserRequest): Promise { + const response = await api.post('/v1/core/invites', data); + if (response.error) { + throw new Error(response.error); + } + return response.data!; + } +}; diff --git a/frontend/src/lib/api/help.ts b/frontend/src/lib/api/help.ts new file mode 100644 index 0000000..28c8443 --- /dev/null +++ b/frontend/src/lib/api/help.ts @@ -0,0 +1,128 @@ +import { getToken, authStore } from '$lib/auth'; +import { get } from 'svelte/store'; + +const api_url = import.meta.env.VITE_API_URL ?? ''; +const normalizedApiUrl = api_url ? (api_url.endsWith('/') ? api_url : `${api_url}/`) : '/'; +const BASE_URL = `${normalizedApiUrl}v1/core/help-center`; + +function getAuthToken(): string | null { + // 1. First try getToken() which checks Keycloak and localStorage + let token = getToken(); + + // 2. If somehow empty, explicitly check authStore value + if (!token) { + const auth = get(authStore); + token = auth.token; + } + + return token; +} + +function getHeaders() { + const token = getAuthToken(); + return { + 'Content-Type': 'application/json', + ...(token ? { 'Authorization': `Bearer ${token}` } : {}) + }; +} + +export interface HelpArticle { + uuid: string; + slug: string; + title: string; + content: string; + updated_at: string; + last_editor: string; + category?: string; + order?: number; + content_type: string; + file_url?: string; + file_size?: number; + mime_type?: string; + context_path?: string; + tags?: string; +} + +export const helpApi = { + async listArticles(): Promise { + const response = await fetch(`${BASE_URL}/articles/`, { headers: getHeaders() }); + if (!response.ok) throw new Error('Failed to fetch articles'); + return response.json(); + }, + + async getArticle(uuid: string): Promise { + const response = await fetch(`${BASE_URL}/articles/${uuid}/`, { headers: getHeaders() }); + if (!response.ok) throw new Error('Failed to fetch article'); + return response.json(); + }, + + async updateArticle(uuid: string, data: Partial): Promise { + const response = await fetch(`${BASE_URL}/articles/${uuid}/`, { + method: 'PATCH', + headers: getHeaders(), + body: JSON.stringify(data) + }); + if (response.status === 403) throw new Error('No tienes permisos para editar artículos (Requiere rol Admin)'); + if (!response.ok) throw new Error('Error al guardar cambios'); + return response.json(); + }, + + async createArticle(data: Partial): Promise { + const response = await fetch(`${BASE_URL}/articles/`, { + method: 'POST', + headers: getHeaders(), + body: JSON.stringify(data) + }); + if (response.status === 403) throw new Error('No tienes permisos para crear artículos (Requiere rol Admin)'); + if (!response.ok) throw new Error('Error al crear el artículo'); + return response.json(); + }, + + async deleteArticle(uuid: string): Promise { + const response = await fetch(`${BASE_URL}/articles/${uuid}/`, { + method: 'DELETE', + headers: getHeaders() + }); + if (response.status === 403) throw new Error('No tienes permisos para eliminar (Requiere rol Admin)'); + if (!response.ok) throw new Error('Error al eliminar'); + }, + + async triggerSync(): Promise { + // Opcional: endpoint para forzar sync desde UI si es necesario + }, + + async uploadImage(file: File): Promise<{ url: string }> { + const formData = new FormData(); + formData.append('file', file); + + const token = getAuthToken(); + const response = await fetch(`${BASE_URL}/upload-image/`, { + method: 'POST', + // No Content-Type header for FormData, browser sets it with boundary + headers: { + ...(token ? { 'Authorization': `Bearer ${token}` } : {}) + }, + body: formData + }); + if (response.status === 403) throw new Error('No tienes permisos para subir imágenes (Requiere rol Admin)'); + if (!response.ok) throw new Error('Error al subir imagen'); + return response.json(); + }, + + async uploadAsset(file: File): Promise<{ url: string, filename: string, size: number, mime_type: string }> { + const formData = new FormData(); + formData.append('file', file); + + const token = getAuthToken(); + const response = await fetch(`${BASE_URL}/upload-asset/`, { + method: 'POST', + headers: { + ...(token ? { 'Authorization': `Bearer ${token}` } : {}) + }, + body: formData + }); + if (response.status === 403) throw new Error('No tienes permisos para subir archivos (Requiere rol Admin)'); + if (!response.ok) throw new Error('Error al subir archivo'); + return response.json(); + } +}; diff --git a/frontend/src/lib/assets/favicon.svg b/frontend/src/lib/assets/favicon.svg new file mode 100644 index 0000000..6ab796d --- /dev/null +++ b/frontend/src/lib/assets/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts new file mode 100644 index 0000000..0e764a7 --- /dev/null +++ b/frontend/src/lib/auth.ts @@ -0,0 +1,1068 @@ +/** + * Servicio de autenticación con Keycloak + * + * Seguridad de tokens: + * - access_token → en memoria (authStore) + cookies no-HttpOnly (una o varias si el JWT es grande) + * - refresh_token → cookie HttpOnly únicamente (JS nunca lo lee directamente) + * - El refresh se hace server-side via /api-sveltekit/auth/silent-refresh + * - NO se usa localStorage para tokens + */ + +import Keycloak from 'keycloak-js'; +import { writable, derived, get } from 'svelte/store'; +import { browser } from '$app/environment'; +import { + clearAccessTokenOnDocument, + getAccessTokenFromDocument, + setAccessTokenOnDocument +} from '$lib/access-token-cookie-browser'; + +// ───────────────────────────────────────────────────────── +// Tipos +// ───────────────────────────────────────────────────────── + +export interface User { + id: string; + username: string; + email?: string; + name?: string; + firstName?: string | null; + lastName?: string | null; + displayName?: string | null; + avatarUrl?: string | null; + workspaceAvatarUrl?: string | null; + legacyAvatarUrl?: string | null; + tenantId?: number; + roles: string[]; + permissions: string[]; + allowedSystems: string[]; // sistemas a los que tiene acceso: "fixed_asset" | "inventory" + // Cache management + profileSyncedAt?: number; // timestamp en ms para cache TTL +} + +export interface AuthState { + isAuthenticated: boolean; + isLoading: boolean; + user: User | null; + token: string | null; +} + +// ───────────────────────────────────────────────────────── +// Configuración de Keycloak +// ───────────────────────────────────────────────────────── + +/** + * Devuelve la URL pública de Keycloak correcta para el browser. + * Si VITE_KEYCLOAK_URL está bakeado con localhost/127.0.0.1 pero el browser + * no está en localhost (producción), se ignora el valor bakeado y se deriva + * del hostname real del browser. Protege contra builds con .env de dev en prod. + */ +function resolveKeycloakUrl(): string { + const configured = (import.meta.env.VITE_KEYCLOAK_URL || '').replace(/\/+$/, ''); + + if (typeof window === 'undefined') { + // SSR: usar el valor configurado tal cual (el server tiene las vars correctas) + return configured || 'http://localhost:8085/kcauth'; + } + + const browserHostname = window.location.hostname; + const isLocalBrowser = browserHostname === 'localhost' || browserHostname === '127.0.0.1'; + + if (configured) { + try { + const parsed = new URL(configured); + const configuredHost = parsed.hostname; + const isLocalConfigured = configuredHost === 'localhost' || configuredHost === '127.0.0.1'; + // Si el build fue con localhost pero el browser NO está en localhost → derivar del hostname real + if (isLocalConfigured && !isLocalBrowser) { + const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:'; + return `${protocol}//${browserHostname}/kcauth`; + } + } catch { + // URL malformada — caer al fallback + } + return configured; + } + + if (isLocalBrowser) { + return 'http://localhost:8085/kcauth'; + } + + const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:'; + return `${protocol}//${browserHostname}/kcauth`; +} + +const keycloakConfig = { + url: resolveKeycloakUrl(), + realm: import.meta.env.VITE_KEYCLOAK_REALM, + clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID +}; + +let keycloakInstance: Keycloak | null = null; +const AUTH_USER_SESSION_KEY = 'app_auth_user_v1'; + +function pickAvatar(...candidates: Array): string | null { + for (const candidate of candidates) { + if (typeof candidate === 'string' && candidate.trim().length > 0) { + return candidate.trim(); + } + } + return null; +} + +function pickText(...candidates: Array): string | null { + for (const candidate of candidates) { + if (typeof candidate === 'string') { + const value = candidate.trim(); + if (value.length > 0) { + return value; + } + } + } + return null; +} + +function readUserFromSession(): User | null { + if (!browser) return null; + try { + const raw = sessionStorage.getItem(AUTH_USER_SESSION_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as User; + if (!parsed || typeof parsed !== 'object') return null; + if (!parsed.id || !parsed.username) return null; + return parsed; + } catch { + return null; + } +} + +function persistUserInSession(user: User | null): void { + if (!browser) return; + if (!user) { + sessionStorage.removeItem(AUTH_USER_SESSION_KEY); + return; + } + sessionStorage.setItem(AUTH_USER_SESSION_KEY, JSON.stringify(user)); +} + +// ───────────────────────────────────────────────────────── +// Auth store (tokens solo en memoria) +// ───────────────────────────────────────────────────────── + +const createAuthStore = () => { + const { subscribe, set, update } = writable({ + isAuthenticated: false, + isLoading: true, + user: null, + token: null + }); + + return { + subscribe, + setAuthenticated: (authenticated: boolean) => + update((s) => ({ ...s, isAuthenticated: authenticated })), + setLoading: (loading: boolean) => + update((s) => ({ ...s, isLoading: loading })), + setUser: (user: User | null) => { + if (user === null) { + persistUserInSession(null); + update((s) => ({ ...s, user: null })); + return; + } + update((s) => { + const prev = s.user; + const permissions = preserveNonEmptyArray(user.permissions, prev?.permissions); + const roles = preserveNonEmptyArray(user.roles, prev?.roles); + const allowedSystems = preserveNonEmptyArray(user.allowedSystems, prev?.allowedSystems); + + if ( + prev && + Array.isArray(user.permissions) && + user.permissions.length === 0 && + (prev.permissions?.length ?? 0) > 0 + ) { + console.debug( + '[auth] setUser: previene downgrade de permissions', + prev.permissions.length, + '→ 0' + ); + } + + const merged: User = { ...user, permissions, roles, allowedSystems }; + persistUserInSession(merged); + return { ...s, user: merged }; + }); + }, + /** Asignación directa sin guard. Usar solo cuando el backend confirma el estado (p. ej. syncCompanyPermissions). */ + setUserUnsafe: (user: User | null) => { + persistUserInSession(user); + update((s) => ({ ...s, user })); + }, + /** Limpia solo el usuario del store (logout parcial). Para logout completo usar reset(). */ + clearUser: () => { + persistUserInSession(null); + update((s) => ({ ...s, user: null })); + }, + setToken: (token: string | null) => update((s) => ({ ...s, token })), + /** ⚠️ Los tokens ya NO se guardan en localStorage; solo en memoria. */ + setTokens: (accessToken: string, _refreshToken?: string) => { + update((s) => ({ ...s, token: accessToken })); + // El refresh_token llega en cookie HttpOnly desde el servidor; + // el cliente no lo almacena ni lo lee en ningún momento. + }, + reset: () => + { + persistUserInSession(null); + set({ + isAuthenticated: false, + isLoading: false, + user: null, + token: null + }); + } + }; +}; + +export const authStore = createAuthStore(); + +export const isAuthenticated = derived(authStore, ($a) => $a.isAuthenticated); +export const currentUser = derived(authStore, ($a) => $a.user); + +/** + * Indica si los permisos RBAC del usuario para la compañía activa ya se hidrataron + * en cliente (vía {@link syncCompanyPermissions} o tras la inicialización del + * dashboard si no hay compañía). Las pantallas que muestran 403 según permisos + * deben esperar a que esto sea `true` antes de decidir, para evitar el flash + * de "Acceso restringido" en el primer render. + */ +export const permissionsHydrated = writable(false); + +/** true mientras `refreshPermissions` revalida permisos en segundo plano (sidebar). */ +export const permissionsRefreshing = writable(false); + +export function markPermissionsHydrated(): void { + permissionsHydrated.set(true); +} + +/** No sobrescribir permisos RBAC con [] de /v1/auth/me (Hub no es fuente de verdad). + * Defensa en profundidad: el guard de authStore.setUser ya cubre esto. */ +function mergePermissionsFromHub(incoming: unknown, previous: string[] | undefined): string[] { + if (Array.isArray(incoming) && incoming.length > 0) return incoming; + if (previous && previous.length > 0) return previous; + return Array.isArray(incoming) ? (incoming as string[]) : []; +} + +/** Preserva roles previos (p. ej. admin del Hub) al fusionar con /v1/auth/me. + * Defensa en profundidad: el guard de authStore.setUser ya cubre esto. */ +function mergeRolesFromHub(incoming: unknown, previous: string[] | undefined): string[] { + const prev = previous ?? []; + if (Array.isArray(incoming) && incoming.length > 0) { + return Array.from(new Set([...prev, ...incoming])); + } + return prev; +} + +/** + * Merge defensivo: preserva el array previo si el incoming es undefined, + * null o [] (cuando previo no era vacío). Un incoming con items siempre + * se respeta — la revocación parcial sí es válida. + * + * Para una revocación TOTAL legítima usa `authStore.setUserUnsafe()`. + */ +export function preserveNonEmptyArray( + incoming: T[] | null | undefined, + previous: T[] | undefined +): T[] { + const prev = previous ?? []; + if (incoming == null) return prev; + if (Array.isArray(incoming) && incoming.length === 0 && prev.length > 0) { + return prev; + } + return Array.isArray(incoming) ? incoming : prev; +} + +// ───────────────────────────────────────────────────────── +// Cache local de permisos del sidebar (localStorage, TTL 5 min) +// ───────────────────────────────────────────────────────── + +const SIDEBAR_PERMS_CACHE_PREFIX = 'a76:perms:v1:'; +const SIDEBAR_PERMS_CACHE_TTL_MS = 5 * 60 * 1000; + +interface SidebarPermsCacheEntry { + permissions: string[]; + roles: string[]; + allowedSystems: string[]; + tenantId?: number; + cachedAt: number; +} + +function buildSidebarPermsCacheKey(userId: string, companyId: number): string { + return `${SIDEBAR_PERMS_CACHE_PREFIX}u${userId}:c${companyId}`; +} + +function saveSidebarPermsCache( + userId: string, + companyId: number, + data: Omit +): void { + if (!browser) return; + try { + const entry: SidebarPermsCacheEntry = { ...data, cachedAt: Date.now() }; + localStorage.setItem(buildSidebarPermsCacheKey(userId, companyId), JSON.stringify(entry)); + } catch { + // localStorage puede estar restringido — no romper la app + } +} + +export function loadSidebarPermsCache( + userId: string, + companyId: number +): SidebarPermsCacheEntry | null { + if (!browser) return null; + try { + const raw = localStorage.getItem(buildSidebarPermsCacheKey(userId, companyId)); + if (!raw) return null; + const entry = JSON.parse(raw) as SidebarPermsCacheEntry; + if (!entry || typeof entry !== 'object') return null; + if (!Array.isArray(entry.permissions) || !Array.isArray(entry.roles)) return null; + if (Date.now() - (entry.cachedAt ?? 0) > SIDEBAR_PERMS_CACHE_TTL_MS) return null; + return entry; + } catch { + return null; + } +} + +function clearSidebarPermsCache(userId?: string): void { + if (!browser) return; + try { + const keys: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (!key || !key.startsWith(SIDEBAR_PERMS_CACHE_PREFIX)) continue; + if (userId === undefined || key.includes(`u${userId}:`)) { + keys.push(key); + } + } + for (const key of keys) { + localStorage.removeItem(key); + } + } catch { + // silencioso + } +} + +/** + * Códigos de rol que dan bypass total a checks de permiso: + * - `super_admin`: rol local de la compañía (fuente de verdad post-desacoplamiento). + * - `admin`: rol del realm Keycloak (preservado por compat con `/v1/auth/me` del Hub). + * - `hub_admin`: super-admin atestado por el Hub. + */ +const ADMIN_ROLE_CODES: ReadonlySet = new Set(['super_admin', 'admin', 'hub_admin']); + +export function userIsAdmin(user: User | null): boolean { + if (!user) return false; + return user.roles.some((role) => ADMIN_ROLE_CODES.has(role)); +} + +/** + * Verifica si el usuario tiene un permiso específico. + * `user.permissions` debe incluir códigos de la app (p. ej. `user.view`); se + * cargan vía {@link syncCompanyPermissions} desde `/v1/core/permissions/me`. + * + * Bypass para super-admins: los códigos en {@link ADMIN_ROLE_CODES} dan acceso + * total sin requerir un permiso granular específico. + */ +export function userHasPermission(user: User | null, permission: string): boolean { + if (!user) return false; + return userIsAdmin(user) || user.permissions.includes(permission); +} + +// ───────────────────────────────────────────────────────── +// Inicialización +// ───────────────────────────────────────────────────────── + +/** + * Inicializa el estado de autenticación en el cliente. + * - Si hay un access_token en la cookie no-HttpOnly, lo usa. + * - En cualquier caso intenta inicializar Keycloak JS (para el flujo SSO). + */ +export const initAuth = async (): Promise => { + if (!browser) return false; + + try { + authStore.setLoading(true); + + const sessionUser = readUserFromSession(); + if (sessionUser) { + authStore.setUser(sessionUser); + } + + // Restaurar token desde cookie no-HttpOnly (password login flow) + const cookieToken = getAccessTokenFromDocument(); + if (cookieToken) { + authStore.setToken(cookieToken); + authStore.setAuthenticated(true); + await loadUserInfo(cookieToken).catch(() => { }); + authStore.setLoading(false); + return true; + } + + // Sin token local, intentar Keycloak JS (flujo SSO) + const authenticated = await initKeycloak(); + authStore.setLoading(false); + return authenticated; + } catch (err) { + console.error('[auth] Error en initAuth:', err); + authStore.setLoading(false); + return false; + } +}; + +/** Inicializa Keycloak JS para el flujo SSO con PKCE */ +export const initKeycloak = async (): Promise => { + if (!browser) return false; + + try { + keycloakInstance = new Keycloak(keycloakConfig); + + const authenticated = await keycloakInstance.init({ + onLoad: 'check-sso', + silentCheckSsoRedirectUri: window.location.origin + '/silent-check-sso.html', + pkceMethod: 'S256', + checkLoginIframe: false + }); + + if (authenticated) { + await updateAuthState(); + setupKeycloakTokenHooks(); + } + + return authenticated; + } catch (err) { + console.error('[auth] Error inicializando Keycloak:', err); + return false; + } +}; + +let previousTenantId: number | undefined = undefined; + +const updateAuthState = async () => { + if (!keycloakInstance?.authenticated) { + authStore.reset(); + persistUserInSession(null); + return; + } + + try { + const profile = await keycloakInstance.loadUserProfile(); + const token = keycloakInstance.token ?? null; + const parsed = keycloakInstance.tokenParsed as any; + + // Roles y tenant_id se obtienen desde el backend (/v1/auth/me y /permissions/me), + // no desde claims del JWT de Keycloak. + const previousUser = get(authStore).user; + const tenantId: number | undefined = previousUser?.tenantId; + const roles: string[] = previousUser?.roles ?? []; + + const tenantChanged = previousTenantId !== undefined && previousTenantId !== tenantId; + + // Obtener permisos actuales para evitar que el SSO los borre si fallara el fetch posterior + let currentPerms: string[] = []; + try { + const { get } = await import('svelte/store'); + const currentState = get(authStore); + currentPerms = currentState.user?.permissions || []; + } catch { } + + const firstName = pickText(profile.firstName, parsed?.given_name, previousUser?.firstName); + const lastName = pickText(profile.lastName, parsed?.family_name, previousUser?.lastName); + const fullNameFromParts = pickText( + firstName && lastName ? `${firstName} ${lastName}` : null, + firstName, + lastName + ); + const username = pickText( + profile.username, + parsed?.preferred_username, + parsed?.username, + previousUser?.username + ) ?? ''; + const name = pickText( + profile.firstName || profile.lastName ? `${profile.firstName ?? ''} ${profile.lastName ?? ''}` : null, + fullNameFromParts, + parsed?.name, + previousUser?.name, + username + ) ?? username; + + const user: User = { + id: pickText(profile.id, parsed?.sub, previousUser?.id) ?? '', + username, + email: pickText(profile.email, parsed?.email, previousUser?.email) ?? undefined, + name, + firstName, + lastName, + displayName: pickText(name, previousUser?.displayName, username), + avatarUrl: pickAvatar(previousUser?.avatarUrl), + workspaceAvatarUrl: pickAvatar(previousUser?.workspaceAvatarUrl), + legacyAvatarUrl: pickAvatar(previousUser?.legacyAvatarUrl), + tenantId, + roles, + // Los permisos efectivos vienen del backend (/permissions/me); el JWT no decide autorización. + permissions: currentPerms, + allowedSystems: previousUser?.allowedSystems ?? [], + profileSyncedAt: previousUser?.profileSyncedAt + }; + + authStore.setAuthenticated(true); + authStore.setUser(user); + authStore.setToken(token); + + // ⚠️ IMPORTANTE: El SSO original de Keycloak no inyecta los permisos granulares + // que viven en la base de datos de PostgreSQL en nuestro `permissions: parsed?.permissions`. + // Necesitamos hacer polling a /auth/me para que `user.permissions` se rellene. + if (token) { + await loadUserInfo(token).catch(() => { }); + } + + if (tenantChanged && browser) { + try { + const { companyStore } = await import('./stores/company.svelte'); + companyStore.clear(); + } catch { } + } + + previousTenantId = tenantId; + } catch (err) { + console.error('[auth] Error actualizando estado:', err); + authStore.reset(); + } +}; + +// ───────────────────────────────────────────────────────── +// Keycloak JS token hooks (solo para el flujo SSO) +// ───────────────────────────────────────────────────────── + +/** + * Configura los callbacks de Keycloak JS para notificar al SessionManager + * sobre cambios de token y eventos de sesión SSO. + */ +const setupKeycloakTokenHooks = () => { + if (!keycloakInstance) return; + + keycloakInstance.onTokenExpired = () => { + keycloakInstance + ?.updateToken(70) + .then((refreshed) => { + if (refreshed && keycloakInstance?.token) { + authStore.setToken(keycloakInstance.token); + import('./session-manager') + .then(({ getSessionManager }) => { + getSessionManager()?.updateToken(keycloakInstance!.token!); + }) + .catch(() => { }); + } + }) + .catch(() => { + console.error('[auth] No se pudo refrescar el token de Keycloak'); + void logout(); + }); + }; + + keycloakInstance.onAuthRefreshSuccess = () => { + if (keycloakInstance?.token) authStore.setToken(keycloakInstance.token); + }; + + keycloakInstance.onAuthRefreshError = () => { + console.error('[auth] Error en refresh de Keycloak — cerrando sesión'); + void logout(); + }; + + keycloakInstance.onAuthLogout = () => { + authStore.reset(); + }; +}; + +// ───────────────────────────────────────────────────────── +// Login +// ───────────────────────────────────────────────────────── + +/** Inicia sesión con Keycloak (OAuth redirect flow) */ +export const loginWithKeycloak = async (tenantSlug?: string) => { + if (!keycloakInstance) { + console.error('[auth] Keycloak no está inicializado'); + return; + } + const options: any = { redirectUri: window.location.origin + '/callback' }; + if (tenantSlug) options.loginHint = tenantSlug; + await keycloakInstance.login(options); +}; + +/** + * Login con usuario/contraseña (legacy — el login principal es via form action del servidor). + * Los tokens se guardan en cookies no-HttpOnly (fragmentadas si hace falta) y en memoria (authStore). + * NO se guardan en localStorage. + */ +export const login = async (credentials: { + username: string; + password: string; + tenant_slug: string; +}): Promise<{ success: boolean; error?: string; data?: any }> => { + try { + const { api } = await import('./api'); + const response = await api.auth.login(credentials); + + if (response.error) { + return { success: false, error: response.error }; + } + + const loginData = response.data; + if (loginData?.access_token) { + // Guardar en memoria y en cookie no-HttpOnly para SSR + authStore.setToken(loginData.access_token); + authStore.setAuthenticated(true); + setAccessTokenOnDocument(loginData.access_token); + // El refresh_token llega en cookie HttpOnly desde el servidor. + // NO lo guardamos en JS. + await loadUserInfo(loginData.access_token); + } + + return { success: true, data: loginData }; + } catch (err) { + console.error('[auth] Error en login:', err); + return { success: false, error: 'Error de conexión con el servidor' }; + } +}; + +// ───────────────────────────────────────────────────────── +// User info +// ───────────────────────────────────────────────────────── + +/** + * Permisos efectivos RBAC de la app para la compañía (backend: GET .../permissions/me). + * Sin esto, `userHasPermission` solo ve lo que venga en /auth/me del Hub. + */ +export async function syncCompanyPermissions(companyId: number): Promise { + if (!browser || !Number.isFinite(companyId)) return; + + // Pre-popular desde localStorage para que el sidebar renderice sin parpadeo + const preUserId = get(authStore).user?.id; + if (preUserId) { + const cached = loadSidebarPermsCache(preUserId, companyId); + if (cached) { + const s = get(authStore); + if (s.user) { + const mergedRoles = Array.from(new Set([...(s.user.roles ?? []), ...cached.roles])); + authStore.setUser({ + ...s.user, + permissions: cached.permissions, + roles: mergedRoles, + allowedSystems: + cached.allowedSystems.length > 0 + ? (cached.allowedSystems as import('./stores/system.svelte').SystemType[]) + : s.user.allowedSystems, + tenantId: cached.tenantId ?? s.user.tenantId + }); + permissionsHydrated.set(true); + } + } + } + + try { + const { api } = await import('./api'); + const res = await api.get<{ + permissions: string[]; + roles?: string[]; + allowed_systems?: string[]; + tenant_id?: number | null; + }>(`/v1/core/permissions/me?company_id=${companyId}`); + if (res.error || res.data === undefined) return; + const perms = res.data.permissions; + if (!Array.isArray(perms)) return; + const state = get(authStore); + if (!state.user) return; + const { systemStore } = await import('./stores/system.svelte'); + const allowedSystems = (res.data.allowed_systems ?? []) as import('./stores/system.svelte').SystemType[]; + const rolesFromBackend = Array.isArray(res.data.roles) ? res.data.roles : null; + const tenantFromBackend = + typeof res.data.tenant_id === 'number' && Number.isFinite(res.data.tenant_id) + ? res.data.tenant_id + : null; + + // Merge no destructivo de roles: preservamos los roles atestados por el + // Hub (p. ej. `admin` del realm Keycloak en /v1/auth/me) y agregamos los + // roles locales devueltos por el backend (`super_admin`, etc.). Si los + // roles locales sobrescribieran a los del Hub, el bypass de admin se + // rompería entre la primera hidratación y la sincronización por compañía. + const previousRoles = state.user.roles ?? []; + const mergedRoles = + rolesFromBackend === null + ? previousRoles + : Array.from(new Set([...previousRoles, ...rolesFromBackend])); + + // El backend es fuente de verdad para permisos de la compañía activa. + // Si retornó [] es porque el usuario realmente no tiene permisos aquí; + // el guard de setUser preservaría los viejos (incorrectos). Por eso unsafe. + authStore.setUserUnsafe({ + ...state.user, + permissions: perms, + roles: mergedRoles, + tenantId: tenantFromBackend ?? state.user.tenantId, + // Preservar allowedSystems del SSR si el API no los retorna (seed pendiente) + allowedSystems: allowedSystems.length > 0 ? allowedSystems : (state.user.allowedSystems ?? []) + }); + + // Solo reinicializar el systemStore si el API retorna sistemas explícitos. + // Si está vacío, preservar el estado establecido por SSR para evitar resetear activeSystem a null. + if (allowedSystems.length > 0) { + const cookieSystem = typeof document !== 'undefined' + ? document.cookie.match(/(?:^|;\s*)active_system=([^;]+)/)?.[1] ?? null + : null; + systemStore.initialize(allowedSystems, cookieSystem); + } + + // Persistir en localStorage para que el sidebar pre-popule sin parpadeo en la próxima sesión + const freshUserId = get(authStore).user?.id; + if (freshUserId) { + saveSidebarPermsCache(freshUserId, companyId, { + permissions: perms, + roles: mergedRoles, + allowedSystems: allowedSystems.length > 0 + ? (allowedSystems as string[]) + : ((get(authStore).user?.allowedSystems ?? []) as string[]), + tenantId: tenantFromBackend ?? get(authStore).user?.tenantId + }); + } + } catch (e) { + console.warn('[auth] syncCompanyPermissions:', e); + } finally { + // Levanta el flag aunque la sync falle: si no se pudo, las pantallas + // quedan con lo que vino del SSR y deben dejar de mostrar el loader. + permissionsHydrated.set(true); + } +} + +export const refreshPermissions = async (): Promise => { + const token = getToken(); + if (!token) return false; + + permissionsRefreshing.set(true); + try { + // RBAC de compañía primero: evita que /auth/me vacíe permisos antes del sync real. + try { + const { companyStore } = await import('./stores/company.svelte'); + const cid = companyStore.activeCompany?.id; + if (cid) await syncCompanyPermissions(cid); + } catch { + // ignore + } + await loadUserInfo(token); + return true; + } finally { + permissionsRefreshing.set(false); + } +}; + +const loadUserInfo = async (token: string) => { + try { + authStore.setToken(token); + const { api } = await import('./api'); + const response = await api.auth.me(); + if (response.data) { + const previousUser = get(authStore).user; + const d = response.data; + const workspaceAvatarUrl = pickAvatar( + d.workspaceAvatarUrl, + d.workspace_avatar_url, + d.avatar_url, + d.avatarUrl, + d.picture, + d.photo, + previousUser?.workspaceAvatarUrl + ); + const legacyAvatarUrl = pickAvatar( + d.legacyAvatarUrl, + d.legacy_avatar_url, + d.avatar, + d.photo, + d.picture, + previousUser?.legacyAvatarUrl + ); + const avatarUrl = pickAvatar(workspaceAvatarUrl, legacyAvatarUrl, previousUser?.avatarUrl); + + // Merge no destructivo: nunca pisar datos válidos con campos vacíos + const firstName = pickText(d.first_name, d.firstName, previousUser?.firstName); + const lastName = pickText(d.last_name, d.lastName, previousUser?.lastName); + const username = pickText( + d.preferred_username, + d.username, + previousUser?.username + ) ?? ''; + const nameFromParts = pickText( + firstName && lastName ? `${firstName} ${lastName}` : null, + firstName, + lastName + ); + const name = pickText( + d.name, + nameFromParts, + previousUser?.name, + username + ) ?? username; + const displayName = pickText( + d.displayName, + d.display_name, + name, + username + ) ?? username; + const email = pickText(d.email, previousUser?.email) ?? undefined; + const userId = pickText(d.sub, d.id, previousUser?.id) ?? ''; + + console.debug('[avatar][auth.loadUserInfo] /v1/auth/me avatar_url recibido:', workspaceAvatarUrl ?? '(null)'); + console.debug('[avatar][auth.loadUserInfo] avatar final para authStore:', avatarUrl ?? '(null)'); + console.debug('[profile][auth.loadUserInfo] first_name:', firstName ?? '(null)', 'last_name:', lastName ?? '(null)', 'avatar_url:', workspaceAvatarUrl ?? '(null)'); + + authStore.setUser({ + id: userId, + username, + email, + name, + firstName, + lastName, + displayName, + avatarUrl, + workspaceAvatarUrl, + legacyAvatarUrl, + tenantId: d.tenant_id ?? previousUser?.tenantId, + roles: mergeRolesFromHub(d.roles, previousUser?.roles), + permissions: mergePermissionsFromHub(d.permissions, previousUser?.permissions), + allowedSystems: previousUser?.allowedSystems ?? [], + profileSyncedAt: Date.now() + }); + } + } catch (err) { + console.error('[auth] Error cargando info del usuario:', err); + } +}; + +/** + * Sincroniza el perfil del usuario desde /v1/auth/me + * - Valida cache TTL (5 minutos) antes de hacer fetch + * - Extrae first_name, last_name, avatar_url + * - Retorna objeto con campos de perfil para UI o update de store + * + * Uso: + * ``` + * const profile = await syncUserProfile(accessToken); + * if (profile) { + * // profile.firstName, profile.lastName, profile.displayName, profile.avatarUrl + * } + * ``` + */ +export const syncUserProfile = async (accessToken?: string): Promise<{ + firstName: string | null; + lastName: string | null; + displayName: string | null; + avatarUrl: string | null; + workspaceAvatarUrl: string | null; + legacyAvatarUrl: string | null; + rawProfileSyncedAt: number; +} | null> => { + if (!browser) return null; + + try { + // Validar cache TTL: 5 minutos (300000ms) + const PROFILE_CACHE_TTL = 5 * 60 * 1000; + const { get } = await import('svelte/store'); + const currentState = get(authStore); + const now = Date.now(); + + if ( + currentState.user?.profileSyncedAt && + (now - currentState.user.profileSyncedAt) < PROFILE_CACHE_TTL + ) { + console.debug('[profile][sync] Cache válido, no re-fetching /v1/auth/me'); + return { + firstName: currentState.user.firstName ?? null, + lastName: currentState.user.lastName ?? null, + displayName: currentState.user.displayName ?? null, + avatarUrl: currentState.user.avatarUrl ?? null, + workspaceAvatarUrl: currentState.user.workspaceAvatarUrl ?? null, + legacyAvatarUrl: currentState.user.legacyAvatarUrl ?? null, + rawProfileSyncedAt: currentState.user.profileSyncedAt + }; + } + + const token = accessToken || getToken(); + if (!token) { + console.warn('[profile][sync] No token disponible para sincronizar'); + return null; + } + + // Llamar a loadUserInfo que hace fetch a /v1/auth/me + await loadUserInfo(token); + + // Retornar los nuevos valores desde el store + const updatedState = get(authStore); + if (updatedState.user) { + console.debug('[profile][sync] Perfil sincronizado exitosamente'); + return { + firstName: updatedState.user.firstName ?? null, + lastName: updatedState.user.lastName ?? null, + displayName: updatedState.user.displayName ?? null, + avatarUrl: updatedState.user.avatarUrl ?? null, + workspaceAvatarUrl: updatedState.user.workspaceAvatarUrl ?? null, + legacyAvatarUrl: updatedState.user.legacyAvatarUrl ?? null, + rawProfileSyncedAt: updatedState.user.profileSyncedAt ?? 0 + }; + } + + return null; + } catch (err) { + console.error('[profile][sync] Error sincronizando perfil:', err); + return null; + } +}; + +// ───────────────────────────────────────────────────────── +// Logout +// ───────────────────────────────────────────────────────── + +export const logout = async () => { + if (!browser) return; + + try { + // Detener el SessionManager + try { + const { destroySessionManager } = await import('./session-manager'); + destroySessionManager(); + } catch { } + + // Limpiar store de compañías + try { + const { companyStore } = await import('./stores/company.svelte'); + companyStore.clear(); + } catch { } + + // Limpiar cache de permisos del sidebar en localStorage + try { + const userId = get(authStore).user?.id; + clearSidebarPermsCache(userId); + } catch { } + + // Limpiar snapshot visual del sidebar almacenado en sessionStorage + try { + sessionStorage.removeItem('app:sidebar:nav-main:v1'); + } catch { + // ignore + } + + // Limpiar estado en memoria + authStore.reset(); + persistUserInSession(null); + + clearAccessTokenOnDocument(); + // La cookie HttpOnly del refresh_token la limpia el servidor + + // Logout unificado (SSO y password): POST al logout route del servidor. + // Evita redirección visible al endpoint de Keycloak. + if (keycloakInstance) { + try { + keycloakInstance.clearToken(); + } catch {} + } + + const form = document.createElement('form'); + form.method = 'POST'; + form.action = '/logout'; + document.body.appendChild(form); + form.submit(); + } catch (err) { + console.error('[auth] Error durante logout:', err); + const hubBase = (import.meta.env.VITE_HUB_URL || 'https://workspace.aduanasoft.com').replace(/\/+$/, ''); + window.location.href = `${hubBase}/login?return_to=${encodeURIComponent(window.location.origin + '/login?sso_verified=1')}`; + } +}; + +// ───────────────────────────────────────────────────────── +// Token accessors +// ───────────────────────────────────────────────────────── + +export const hasRole = (role: string): boolean => { + if (!keycloakInstance?.authenticated) return false; + return keycloakInstance.hasRealmRole(role); +}; + +/** Obtiene el access token desde memoria (Keycloak JS o authStore) */ +export const getToken = (): string | null => { + // Prioridad 1: Keycloak JS en memoria + if (keycloakInstance?.token) return keycloakInstance.token; + + // Prioridad 2: authStore en memoria + let token: string | null = null; + const unsub = authStore.subscribe((s) => { token = s.token; }); + unsub(); + if (token) return token; + + // Prioridad 3: cookie no-HttpOnly (fallback para acceso inicial antes del onMount) + if (browser) return getAccessTokenFromDocument(); + + return null; +}; + +/** + * Refresca el access token usando el endpoint server-side seguro. + * El servidor lee el refresh_token de la cookie HttpOnly. + * @returns true si el refresh fue exitoso + */ +export const refreshAccessToken = async (): Promise => { + if (!browser) return false; + + // Con Keycloak JS activo, usar su mecanismo nativo + if (keycloakInstance?.authenticated) { + try { + const refreshed = await keycloakInstance.updateToken(70); + if (refreshed || keycloakInstance.token) { + authStore.setToken(keycloakInstance.token ?? null); + return true; + } + } catch { + await logout(); + return false; + } + } + + // Flujo de contraseña: usar el endpoint server-side seguro + try { + const resp = await fetch('/api-sveltekit/auth/silent-refresh', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' } + }); + + if (!resp.ok) { + await logout(); + return false; + } + + const data = await resp.json() as { access_token?: string }; + if (data.access_token) { + authStore.setToken(data.access_token); + setAccessTokenOnDocument(data.access_token); + return true; + } + } catch (err) { + console.error('[auth] Error en refreshAccessToken:', err); + } + + await logout(); + return false; +}; + +/** @deprecated El refresh_token ya no se expone en JS. */ +export const getRefreshToken = (): string | null => { + console.warn('[auth] getRefreshToken() está deprecado — el refresh_token no se expone en JS.'); + return null; +}; + +export const getKeycloakInstance = (): Keycloak | null => keycloakInstance; diff --git a/frontend/src/lib/backend.test.ts b/frontend/src/lib/backend.test.ts new file mode 100644 index 0000000..2b7689d --- /dev/null +++ b/frontend/src/lib/backend.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest' + +// Solo en entorno con DNS `backend` (p. ej. red Docker) y con backend levantado; en Jenkins/CI se omite. +const skipHttpIntegration = process.env.CI === 'true' || Boolean(process.env.JENKINS_URL) + +describe.skipIf(skipHttpIntegration)('backend — health check', () => { + + it('el backend esta corriendo y responde', async () => { + const response = await fetch('http://backend:8000/api/health') + expect(response.status).toBe(200) + }) + + it('el endpoint de facturas responde', async () => { + const response = await fetch('http://backend:8000/api/v1/a76/invoices/?company_id=1', { + headers: { 'Authorization': 'Bearer test' } + }) + // 200 con datos o 401/403 sin token valido — ambos significan que el backend esta vivo + expect([200, 401, 403, 422]).toContain(response.status) + }) + +}) \ No newline at end of file diff --git a/frontend/src/lib/components/app-version.svelte b/frontend/src/lib/components/app-version.svelte new file mode 100644 index 0000000..8c28293 --- /dev/null +++ b/frontend/src/lib/components/app-version.svelte @@ -0,0 +1,90 @@ + + + +
+ {#if loading} +
Cargando versión...
+ {:else if error} +
Error: {error}
+ {:else if versionInfo} +
+ + + + v{versionInfo.version} + + + + {#if versionInfo.debug} + + DEBUG + + {/if} +
+ {/if} +
diff --git a/frontend/src/lib/components/common/Maintenance.svelte b/frontend/src/lib/components/common/Maintenance.svelte new file mode 100644 index 0000000..68dc46b --- /dev/null +++ b/frontend/src/lib/components/common/Maintenance.svelte @@ -0,0 +1,80 @@ + + +
+ {#if visible} +
+
+ +
+ +

+ {m['common.maint_title']()} +

+ +

+ {m['common.maint_desc']()} +

+ + + {/if} +
+ + diff --git a/frontend/src/lib/components/dashboard/chart-card.svelte b/frontend/src/lib/components/dashboard/chart-card.svelte new file mode 100644 index 0000000..fd8df43 --- /dev/null +++ b/frontend/src/lib/components/dashboard/chart-card.svelte @@ -0,0 +1,81 @@ + + + + + {title} + {#if description} + {description} + {/if} + + + {#if data.length === 0} +
+

{m.dashboard_no_data_available_short()}

+
+ {:else if type === 'bar'} +
+ {#each data as item, i} +
+
+ {i + 1} + {item.label} + {item.value.toLocaleString()} +
+
+
+
+
+ {/each} +
+ {:else if type === 'pie'} +
+ {#each data as item, i} +
+
+
+
{item.label}
+
{item.value.toLocaleString()}
+
+
+ {/each} +
+ {/if} +
+
diff --git a/frontend/src/lib/components/dashboard/common/error-state.svelte b/frontend/src/lib/components/dashboard/common/error-state.svelte new file mode 100644 index 0000000..f25dde2 --- /dev/null +++ b/frontend/src/lib/components/dashboard/common/error-state.svelte @@ -0,0 +1,158 @@ + + +
+ + +
+ + +
+
+
+
+ {#if isForbidden} + + {:else} + + {/if} +
+
+
+ +
+ + {title} + + + {displayError} + +
+
+ + +
+ {#if isForbidden && permissionCode} +
+ Identificador de Permiso + + {permissionCode} + +
+ {/if} + + {#if isServerError && error && !isForbidden} +
+
+

+ {error.length > 150 ? error.substring(0, 150) + '...' : error} +

+
+
+ {/if} + +
+ {#if isServerError} + + {/if} + + +
+ + + + Ir al Inicio del Dashboard + +
+
+ + +
+

+ Si consideras que esto es un error o el problema persiste, contacta al soporte técnico. +

+ {#if activeCompany} +

+ CID: {activeCompany.id} | TS: {new Date().toISOString()} +

+ {/if} +
+
+
+
+ + diff --git a/frontend/src/lib/components/dashboard/common/infinite-data-table.svelte b/frontend/src/lib/components/dashboard/common/infinite-data-table.svelte new file mode 100644 index 0000000..de66cf3 --- /dev/null +++ b/frontend/src/lib/components/dashboard/common/infinite-data-table.svelte @@ -0,0 +1,253 @@ + + +
+
+ + + {#each table.getHeaderGroups() as headerGroup (headerGroup.id)} + {@const headerList = headerGroup.headers} + {@const lastHeaderColId = headerList[headerList.length - 1]?.column.id} + + {#each headerList as header (header.id)} + {@const colId = header.column.id} + + {#if !header.isPlaceholder} + + {/if} + + {/each} + + {/each} + + + {#if table.getRowModel().rows.length} + {#each table.getRowModel().rows as row (row.id)} + {@const visibleCells = row.getVisibleCells()} + {@const lastCellColId = visibleCells[visibleCells.length - 1]?.column.id} + onRowClick?.(row.original)} + ondblclick={() => onRowDoubleClick?.(row.original)} + > + {#each visibleCells as cell (cell.id)} + {@const colId = cell.column.id} + + + + {/each} + + {/each} + {:else} + + + {emptyMessage} + + + {/if} + + {#if hasMore} + + +
+ {#if loading} +
+
+ Cargando más registros... +
+ {:else} +
+ + Desplázate para cargar más + +
+ {/if} +
+
+
+ {/if} +
+
+
+
diff --git a/frontend/src/lib/components/dashboard/common/permission-denied.svelte b/frontend/src/lib/components/dashboard/common/permission-denied.svelte new file mode 100644 index 0000000..4219acf --- /dev/null +++ b/frontend/src/lib/components/dashboard/common/permission-denied.svelte @@ -0,0 +1,99 @@ + + +
+ +
+ + +
+
+
+
+ +
+
+
+ +
+
+ Acceso Restringido + + {displayError} + +
+
+
+ + +
+ {#if permissionCode} +
+ Identificador de Permiso + + {permissionCode} + +
+ {/if} + +
+ +
+
+
+ + +

+ Si consideras que esto es un error, contacta al administrador del sistema. +

+
+
+
+ + diff --git a/frontend/src/lib/components/dashboard/donut-chart.svelte b/frontend/src/lib/components/dashboard/donut-chart.svelte new file mode 100644 index 0000000..d6fdeee --- /dev/null +++ b/frontend/src/lib/components/dashboard/donut-chart.svelte @@ -0,0 +1,137 @@ + + + + +
+
+ + + {title} + + {m.dashboard_operations_breakdown()} +
+ {#if total > 0} + {total.toLocaleString()} {m.dashboard_ops_short()} + {/if} +
+
+ + + {#if data.length === 0} +
+
+ +
+
+

{m.dashboard_no_data_available()}

+

{m.dashboard_operations_will_appear_here()}

+
+
+ {:else} +
+ + +
+ + + {#each donutSlices() as s} + + {/each} + +
+ {total.toLocaleString()} + {m.dashboard_total()} +
+
+ + +
+ {#each segments as s} +
+
+
+ + {s.label} +
+
+ {s.value.toLocaleString()} + {s.pct.toFixed(1)}% +
+
+
+
+
+
+ {/each} +
+ +
+ {/if} +
+
diff --git a/frontend/src/lib/components/dashboard/kpi-card.svelte b/frontend/src/lib/components/dashboard/kpi-card.svelte new file mode 100644 index 0000000..ae0780a --- /dev/null +++ b/frontend/src/lib/components/dashboard/kpi-card.svelte @@ -0,0 +1,54 @@ + + +
+ {#if Icon} +
+ +
+ {/if} + +
+

{metric.label}

+
+ + {metric.value.toLocaleString()}{#if metric.unit}{metric.unit}{/if} + + {#if metric.percentage_change !== undefined && TrendIcon && metric.trend} + + + {Math.abs(metric.percentage_change).toFixed(1)}% + + {/if} +
+
+
diff --git a/frontend/src/lib/components/dashboard/settings/DynamicSettingForm.svelte b/frontend/src/lib/components/dashboard/settings/DynamicSettingForm.svelte new file mode 100644 index 0000000..c3162a6 --- /dev/null +++ b/frontend/src/lib/components/dashboard/settings/DynamicSettingForm.svelte @@ -0,0 +1,79 @@ + + +
+ +
+ + +
+ + +
+ {#if hasResults} +
+ {#each filteredFields as field (field)} +
+ handleFieldChange(field, val)} + /> +
+ {/each} +
+ {:else} +
+ +

No se encontraron parámetros

+
+ {/if} +
+
+ + diff --git a/frontend/src/lib/components/dashboard/settings/QsisGenTabsForm.svelte b/frontend/src/lib/components/dashboard/settings/QsisGenTabsForm.svelte new file mode 100644 index 0000000..7a4da15 --- /dev/null +++ b/frontend/src/lib/components/dashboard/settings/QsisGenTabsForm.svelte @@ -0,0 +1,526 @@ + + +
+ + + {#each TABS as tab} + + {tab.label} + + {/each} + + + + +
+ {#each GENERAL_SECTIONS as section} +
+
+

{section.title}

+
+
+
+ {#each section.fields as field} + handleFieldChange(field, val)} + type={field === 'DTA' ? 'number' : undefined} + /> + {/each} +
+
+ {/each} +
+
+ + + +
+ {#each ARCHIVOS_SECTIONS as section} +
+
+

{section.title}

+
+
+
+ {#each section.fields as field} + handleFieldChange(field, val)} + /> + {/each} +
+
+ {/each} +
+
+ + + +
+ {#each CONTINUACION_SECTIONS as section} +
+
+

{section.title}

+
+
+
+ {#each section.fields as field} + handleFieldChange(field, val)} + options={ + field === 'TipoVenRO' ? RULE_VALIDATOR_OPTIONS : + field === 'CalcDepreciacion' ? DEPRECIACION_OPTIONS : + undefined + } + type={ + field === 'TipoVenRO' ? 'select' : + field === 'CalcDepreciacion' ? 'radio' : + field === 'CantVenRO' ? 'number' : + undefined + } + /> + {/each} +
+
+ {/each} +
+
+ + + +
+ {#each CONT2_SECTIONS as section} +
+
+

{section.title}

+
+
+
+ {#each section.fields as field} + handleFieldChange(field, val)} + type="switch" + /> + {/each} +
+
+ {/each} +
+
+ + + +
+ {#each CONT3_SECTIONS as section} +
+
+

{section.title}

+
+
+
+ {#each section.fields as field} + handleFieldChange(field, val)} + type={ + field.toLowerCase().includes('path') || field.toLowerCase().includes('nafta') + ? undefined + : 'switch' + } + /> + {/each} +
+
+ {/each} +
+
+ + + +
+
+

Opciones Adicionales

+
+
+
+ {#each CONT4_FIELDS as field} + handleFieldChange(field, val)} + type="switch" + /> + {/each} +
+
+
+ + + +
+
+

Configuraciones Adicionales

+
+
+
+ {#each CONT5_FIELDS as field} + handleFieldChange(field, val)} + type="switch" + /> + {/each} +
+
+
+ +
+
+ + diff --git a/frontend/src/lib/components/dashboard/settings/SettingCategoryNav.svelte b/frontend/src/lib/components/dashboard/settings/SettingCategoryNav.svelte new file mode 100644 index 0000000..d5967a0 --- /dev/null +++ b/frontend/src/lib/components/dashboard/settings/SettingCategoryNav.svelte @@ -0,0 +1,72 @@ + + +
+ {#each groups as group} +
+

+ + {group.title} +

+
+ {#each group.items as item} + + {/each} +
+
+ {/each} +
diff --git a/frontend/src/lib/components/dashboard/settings/SettingFormField.svelte b/frontend/src/lib/components/dashboard/settings/SettingFormField.svelte new file mode 100644 index 0000000..39696a3 --- /dev/null +++ b/frontend/src/lib/components/dashboard/settings/SettingFormField.svelte @@ -0,0 +1,176 @@ + + +
+
+ + + {#if mode === 'select' && options} + + + + {options.find(opt => String(opt.value) === selectValue)?.label || "Seleccionar..."} + + + + {#each options as opt} + + {opt.label} + + {/each} + + + {:else if mode === 'radio'} + + {#each options || [] as opt} +
+ + +
+ {/each} +
+ {:else if mode === 'switch'} + onChange(val ? 1 : 0)} + disabled={disabled} + /> + {:else} +
+ {#if mode === 'path'} +
+ onChange(e.target.value)} + placeholder="Ruta del directorio..." + disabled={disabled} + class="font-mono text-xs text-foreground bg-background" + /> + +
+ {:else if mode === 'password'} + onChange(e.target.value)} + placeholder="••••••••" + disabled={disabled} + class="text-foreground bg-background" + /> + {:else if mode === 'number'} + onChange(Number(e.target.value))} + disabled={disabled} + class="text-right text-foreground bg-background" + /> + {:else} + onChange(e.target.value)} + disabled={disabled} + class="text-foreground bg-background" + /> + {/if} + {#if hint} +

+ {hint} +

+ {/if} +
+ {/if} +
+
+ diff --git a/frontend/src/lib/components/dashboard/settings/SettingHierarchyBadge.svelte b/frontend/src/lib/components/dashboard/settings/SettingHierarchyBadge.svelte new file mode 100644 index 0000000..a6a8d4d --- /dev/null +++ b/frontend/src/lib/components/dashboard/settings/SettingHierarchyBadge.svelte @@ -0,0 +1,31 @@ + + +
+ + {current.label.toUpperCase()} +
diff --git a/frontend/src/lib/components/dashboard/settings/SsisGenTabsForm.svelte b/frontend/src/lib/components/dashboard/settings/SsisGenTabsForm.svelte new file mode 100644 index 0000000..5803f74 --- /dev/null +++ b/frontend/src/lib/components/dashboard/settings/SsisGenTabsForm.svelte @@ -0,0 +1,884 @@ + + +
+ + + {#each TABS as tab} + + {tab.label} + + {/each} + + + +
+ {#each GENERAL_SECTIONS as section} +
+
+

+ {section.title} +

+
+
+ +
+ {#each section.fields as field} + handleFieldChange(field, val)} + /> + {/each} +
+
+ {/each} +
+
+ + +
+ {#each ARCHIVOS_SECTIONS as section} +
+
+

+ {section.title} +

+
+
+ +
+ {#each section.fields as field} + handleFieldChange(field, val)} + /> + {/each} +
+
+ {/each} +
+
+ + +
+ {#each CONTINUACION_SECTIONS as section} +
+
+

+ {section.title} +

+
+
+ +
+ {#each section.fields as field} + handleFieldChange(field, val)} + options={field === 'tipovenro' ? RULE_VALIDATOR_OPTIONS : undefined} + /> + {/each} +
+
+ {/each} +
+
+ + +
+ {#each CONT2_SECTIONS as section} +
+
+

+ {section.title} +

+
+
+ +
+ {#each section.fields as field} + handleFieldChange(field, val)} + /> + {/each} +
+
+ {/each} +
+
+ + +
+ {#each CONT3_SECTIONS as section} +
+
+

+ {section.title} +

+
+
+ +
+ {#each section.fields as field} + handleFieldChange(field, val)} + type={ + field === 'interfaceaatcfpff' ? 'radio' : + (field === 'limitesubensamble' ? 'input' : + (['agregarincreimpo', 'componentebom', 'partesypedimentosporcliente'].includes(field) ? 'switch' : undefined)) + } + options={field === 'interfaceaatcfpff' ? INTERFACE_TC_OPTIONS : undefined} + /> + {/each} +
+
+ {/each} +
+
+ + +
+
+
+ + {#each CONT4_SECTIONS.filter(s => s.id !== 'additional_options') as section} +
+
+

+ {section.title} +

+
+
+ +
+ {#each section.fields as field} + handleFieldChange(field, val)} + disabled={formData.downloadftp !== 'Si' && field !== 'downloadftp'} + type={ + field === 'downloadftp' || field === 'descargarftpolocal' ? 'radio' : + field === 'minsdownlftp' ? 'number' : + field === 'passwordftp' ? 'password' : + undefined + } + options={ + field === 'downloadftp' ? YES_NO_OPTIONS : + field === 'descargarftpolocal' ? FTP_LOCAL_OPTIONS : + undefined + } + hint={ + field === 'directorioftp' ? 'Ejemplo: /Folder 1/SubFolder' : + field === 'pathlocalparadescde' ? 'Ejemplo: C:\\Aduanas\\SCAIISQL' : + undefined + } + {...(section.id === 'ftp_config' && formData.descargarftpolocal === 'Ruta Local' ? { disabled: true } : {})} + {...(section.id === 'local_config' && formData.descargarftpolocal === 'FTP' ? { disabled: true } : {})} + /> + {/each} +
+
+ {/each} +
+ + +
+
+

+ Opciones Adicionales +

+
+
+ +
+ {#each CONT4_SECTIONS.find(s => s.id === 'additional_options')?.fields || [] as field} + handleFieldChange(field, val)} + /> + {/each} +
+
+
+
+
+ + +
+
+ {#each CONT5_SECTIONS as section} +
+
+

+ {section.title} +

+
+
+ +
+ {#each section.fields as field} + handleFieldChange(field, val)} + type={ + field === 'geninformeanexo31' ? 'radio' : + (['usarfechaemisionfactura', 'agregarnumeroembarque', 'hojacalculosepararincrementablesanexo3', 'hojacalculodesglosefacturaanexo3'].includes(field) ? 'switch' : undefined) + } + options={field === 'geninformeanexo31' ? INICIAL_INV_OPTIONS : undefined} + /> + {/each} +
+
+ {/each} +
+
+
+ + +
+
+ +
+
+

+ Opciones de Configuración +

+
+
+ +
+ {#each CONT6_SECTIONS.find(s => s.id === 'configuracion')?.fields || [] as field} +
+ handleFieldChange(field, val)} + disabled={field === 'usarcontroldefechasdeversion' && !formData.parametroauxiliar} + type="switch" + /> +
+ {/each} +
+
+ + +
+ +
+
+

+ Módulos del Sistema +

+
+
+ +
+ {#each CONT6_SECTIONS.find(s => s.id === 'acciones_modulos')?.fields || [] as field} + handleFieldChange(field, val)} + type={field === 'campo18valsaaim3' ? 'switch' : undefined} + /> + {/each} +
+
+ + +
+
+

+ Acciones de Gestión +

+
+
+ +
+ + + + + + + +
+
+
+
+
+
+ + +
+ {#each CONT7_SECTIONS as section} +
+
+

+ {section.title} +

+
+
+ +
+ +
+
+ handleFieldChange('actvaloragre', val)} + /> +
+
+ handleFieldChange('valoragregadogen', val)} + type="input" + /> +
+
+ + {#each section.fields as field} + handleFieldChange(field, val)} + /> + {/each} +
+
+ {/each} +
+
+ +
+
+ + diff --git a/frontend/src/lib/components/dashboard/settings/settings-metadata.ts b/frontend/src/lib/components/dashboard/settings/settings-metadata.ts new file mode 100644 index 0000000..6cdc10d --- /dev/null +++ b/frontend/src/lib/components/dashboard/settings/settings-metadata.ts @@ -0,0 +1,35 @@ +export const SETTINGS_METADATA: Record = { + ssisgen: [ + "consecutivo", "dta", "dtaexpo", "subempresa", "patharch", "patharchtransmision", "pathtransexpo", "pathrespuesta", + "patharchped", "patharchpedconsm", "pathgenimpotemp", "pathgenexpo", "actseguridad", "controldes", "diadesactual", + "diavencimiento", "mensajevenc", "fechades", "factoriva", "validasifra", "decimalespeso", "decimalescant", + "decimalesvalor", "calvalbasetcped", "calvalbasetcpedexpo", "filtrocantidad", "muestraarchcodbarras", "datoshist", + "tipovenro", "cantvenro", "costoplanta", "firmapacking", "advertenciatm", "tomarsaldosvenc", "costoimpofijo", + "valparteexiste", "valmanifusado", "temporalfechapago", "asignadiasantdesc", "diasantdesc", "deshabilitardescparte", + "deshabilitardescparteing", "asignafracameparte", "validadecencant", "usartranspamedocame", "mostraradvertenciaro", + "escondaamexpacking", "calcdutypacking", "parammultiples", "fraccnivelpais", "covefechaemision", "valordllstcfacturaexpo", + "interfaceaaconsolidada", "interfaceaatcfpff", "incluirobscoveobsimpo", "agregarincreimpo", "componentebom", + "limitesubensamble", "actpdfreportes", "patharchpdfimpo", "patharchpdfexpo", "noimprimircons", "mostraradvertenciarovalor", + "partesypedimentosporcliente", "mensajesvurfc", "mostrarpackinglistingles", "omitirempaqueencodigobarras", "restringepaisimpo", + "bloqueoaldesactivarnumerodeparte", "restringpaisexpo", "geninformeanexo31", "utilizarfechapagopeddeundiaanterior", + "utilizarequivalenciasdeumpornumerodeparte", "utilizartitulosalternativosimpresionfactura", "usarfactorconversionpornumerodeparte", + "usarvude128o256", "usartcdelafechapagopedimpoendescarga", "solicitarcontrasenaadministrador", "agregarnumeroembarque", + "utilizarumdeexistenciaentransmisionvu", "utilizarcodigodebrokerdeclienteenmainx30", "hojacalculosepararincrementablesanexo3", + "hojacalculodesglosefacturaanexo3", "usarvaloragregadoenfacturaamericana", "ocultarinformacionfraccion", + "resaltarsaldostempconcolor", "valoragregadoenfacturamexicana", "validarsectorprosecr8", "agregarsubtotalinterfazaa" + ], + ssismex: [ + "consecutivo", "prefijocm", "consecutivocm", "porparteclasemex", "porparteclaseame", "proveedor", "vendidoconsignado", + "vendidoa", "enviadotransferido", "enviadoa", "flete", "paisorigenmex", "numpartemex", "firmafmex", "fraccionimp", + "tipofraccmex", "tasafraccmex", "umequivalentemex", "numparteame", "fraccioname", "paisorigename", "umequivalenteame", + "firmafame", "impordencomp", "decimalespeso", "decimalescant", "decimalesvalor", "decimalescosto", "tipomoneda", + "clavemoneda", "transportista", "conductor", "transporte", "numtransporte", "observacione", "observacioni", + "leyendamex", "leyendaame", "firmaaamex", "firmapmex", "firmaaaame", "firmapame", "firmaeamex", "firmaeame", + "firmasamex", "firmasame", "claveregimen", "claveregimename", "numfactura", "fechafactura", "numeropedimento", + "fechapedimento", "pedimentomex", "clientemex", "claveregmexo", "claveregmexd", "usarvalorameric", "consecutivoas", + "consecutivops", "usatranspfactu", "ocultarfechahora" + ], + qsisgen: [ + "consecutivo", "actvaloragre", "valoragregadogen" + ] +}; diff --git a/frontend/src/lib/components/dashboard/shared/modals/customs-section-selector-dialog.svelte b/frontend/src/lib/components/dashboard/shared/modals/customs-section-selector-dialog.svelte new file mode 100644 index 0000000..7e499bf --- /dev/null +++ b/frontend/src/lib/components/dashboard/shared/modals/customs-section-selector-dialog.svelte @@ -0,0 +1,149 @@ + + + + + + Seleccionar Sección Aduanera + Catálogo general de aduanas y secciones. + + +
+ + +
+ +
+ {#if loading} +
+ +

Cargando catálogo...

+
+ {:else if filteredItems.length === 0} +
+

No se encontraron registros.

+
+ {:else} + + + + + + + + + {#each filteredItems as item} + handleSelect(item)} + onkeydown={(event) => handleRowKeydown(event, item)} + > + + + + {/each} + +
CódigoDescripción
{item.customs_code} +
+ + {item.section_name || '-'} +
+
+ {/if} +
+ + +
+ Mostrando {filteredItems.length} registros +
+ {#if onClear} + + {/if} + +
+
+
diff --git a/frontend/src/lib/components/dashboard/shared/modals/sector-selector-dialog.svelte b/frontend/src/lib/components/dashboard/shared/modals/sector-selector-dialog.svelte new file mode 100644 index 0000000..4871fff --- /dev/null +++ b/frontend/src/lib/components/dashboard/shared/modals/sector-selector-dialog.svelte @@ -0,0 +1,230 @@ + + + + + + Seleccionar Sector PROSEC + + Seleccione el sector del catálogo. Escrolea para ver más. + + + +
+ + +
+ +
+ {#if loading && items.length === 0} +
+ +

Cargando catálogo...

+
+ {:else if items.length === 0} +
+

No se encontraron sectores.

+
+ {:else} + + + + Clave + Descripción + Autorizado + + + + {#each items as item} + handleSelect(item)} + > + +
+ + + {item.key} + +
+
+ + {item.description} + + + + {item.authorized ? 'Sí' : 'No'} + + +
+ {/each} +
+
+ +
+ {#if loadingMore} + + {/if} +
+ {/if} +
+ + +
+ {items.length} de {totalItems} registros +
+ {#if onClear} + + {/if} + +
+
+
diff --git a/frontend/src/lib/components/dashboard/shared/modals/state-selector-dialog.svelte b/frontend/src/lib/components/dashboard/shared/modals/state-selector-dialog.svelte new file mode 100644 index 0000000..2403b9c --- /dev/null +++ b/frontend/src/lib/components/dashboard/shared/modals/state-selector-dialog.svelte @@ -0,0 +1,231 @@ + + + + + + Seleccionar Estado + + Seleccione el estado del catálogo. Escrolea para ver más. + + + +
+ + +
+ +
+ {#if loading && items.length === 0} +
+ +

Cargando catálogo...

+
+ {:else if items.length === 0} +
+

No se encontraron estados.

+
+ {:else} + + + + Clave M3 + Descripción + MEX + + + + {#each items as item} + handleSelect(item)} + > + +
+ + + {item.m3_key} + +
+
+ + {item.description} + + + {item.mex_key || '-'} + +
+ {/each} +
+
+ +
+ {#if loadingMore} + + {/if} +
+ {/if} +
+ + +
+ {items.length} de {totalItems} registros +
+ {#if onClear} + + {/if} + +
+
+
diff --git a/frontend/src/lib/components/dashboard/trend-chart.svelte b/frontend/src/lib/components/dashboard/trend-chart.svelte new file mode 100644 index 0000000..e6d7e26 --- /dev/null +++ b/frontend/src/lib/components/dashboard/trend-chart.svelte @@ -0,0 +1,219 @@ + + + + +
+
+ + + {m.dashboard_operations_trend()} + + {m.dashboard_monthly_evolution()} +
+ + {#if monthlyData.length >= 2} + {@const pct = trendPct()} +
+ {#if pct > 0} + +{pct}% + {:else if pct < 0} + {pct}% + {:else} + 0% + {/if} + vs {m.dashboard_previous_month()} +
+ {/if} +
+
+ + + {#if monthlyData.length === 0} +
+
+ +
+
+

{m.dashboard_no_data_available()}

+

{m.dashboard_data_will_appear_here()}

+
+
+ {:else if monthlyData.length === 1} +
+
+
{monthlyData[0].value.toLocaleString()}
+
+ {m.dashboard_operations_in()} {monthlyData[0].label} +
+
+
+ + {m.dashboard_more_than_one_month_needed()} +
+
+ {:else} + +
+ +
+ + +
+
+
{total.toLocaleString()}
+
{m.dashboard_total()}
+
+
+
{average.toLocaleString()}
+
{m.dashboard_average_per_month()}
+
+
+
{maxValue.toLocaleString()}
+
{m.dashboard_maximum()}
+
+
+ {/if} +
+
diff --git a/frontend/src/lib/components/help/HelpDrawer.svelte b/frontend/src/lib/components/help/HelpDrawer.svelte new file mode 100644 index 0000000..61ef02c --- /dev/null +++ b/frontend/src/lib/components/help/HelpDrawer.svelte @@ -0,0 +1,504 @@ + + + + + +
+
+
+ {#if selectedArticle} + + {/if} +
+ +
+

+ {selectedArticle ? 'Artículo de Ayuda' : 'Centro de Ayuda'} +

+
+
+

Manuales y guías interactivas del sistema.

+
+ +
+ {#if !selectedArticle} + +
+ +
+ + +
+ + + {#if contextualArticles.length > 0 && !searchTerm} +
+
+
+ Recomendado para ti +
+
+
+ {#each contextualArticles as article} + + {/each} +
+
+ {/if} + + +
+
+

+ {searchTerm ? 'Resultados de búsqueda' : 'Manuales del Sistema'} +

+ {#if articles.length > 0} + {articles.length} artículos + {/if} +
+ + {#if isLoading} +
+
+

Cargando base de conocimientos...

+
+ {:else if articles.length === 0} +
+
+ +
+
+

+ {hasError ? 'Error de conexión' : 'Biblioteca vacía'} +

+

+ {hasError + ? 'No pudimos conectar con el servidor de ayuda.' + : 'No hay artículos registrados para esta sección aún.'} +

+
+ {#if hasError} + + {:else if isAdmin} + + {/if} +
+ {:else} + {@const displayList = searchTerm ? filteredArticles : articles} + {#if displayList.length === 0} +
+ +

No encontramos nada para "{searchTerm}"

+ +
+ {:else} +
+ {#each displayList as article} + + {/each} +
+ {/if} + {/if} +
+
+ + +
+
+ + {#if isAdmin} + + {/if} +
+
+ {:else} + +
+
+ + {#if isAdmin && !isEditing} + + {/if} +
+ +
+ {#if isEditing} +
+
+ + +
+
+ + +
+
+ + +
+
+ {:else} +
+
+ {selectedArticle.category || 'General'} +

{selectedArticle.title}

+
+ Por {selectedArticle.last_editor} + + Actualizado: {new Date(selectedArticle.updated_at).toLocaleDateString()} +
+
+ +
+ {#if selectedArticle.content_type === 'pdf' && selectedArticle.file_url} +
+ +
+ {:else} + {#if browser} + {@html renderMarkdown(selectedArticle.content)} + {:else} +
{selectedArticle.content}
+ {/if} + {/if} +
+
+ {/if} +
+
+ {/if} +
+
+
+ + diff --git a/frontend/src/lib/components/keyboard/KeyboardManager.svelte b/frontend/src/lib/components/keyboard/KeyboardManager.svelte new file mode 100644 index 0000000..5ed8474 --- /dev/null +++ b/frontend/src/lib/components/keyboard/KeyboardManager.svelte @@ -0,0 +1,681 @@ + + + { + interactionMode.set('keyboard'); + handleKeydown(e); + }} + onmousedown={() => interactionMode.set('mouse')} + onfocusin={handleFocusIn} +/> + +{#if showHelp} + { + showHelp = false; + restoreHelpFocus(); + }} + /> +{/if} diff --git a/frontend/src/lib/components/keyboard/ShortcutsHelpModal.svelte b/frontend/src/lib/components/keyboard/ShortcutsHelpModal.svelte new file mode 100644 index 0000000..50dd726 --- /dev/null +++ b/frontend/src/lib/components/keyboard/ShortcutsHelpModal.svelte @@ -0,0 +1,195 @@ + + +{#if open} + +{/if} diff --git a/frontend/src/lib/components/license-error-screen.svelte b/frontend/src/lib/components/license-error-screen.svelte new file mode 100644 index 0000000..6e3637f --- /dev/null +++ b/frontend/src/lib/components/license-error-screen.svelte @@ -0,0 +1,100 @@ + + +
+
+ + {#if isHubOffline} +
+ +
+ {:else} +
+ +
+ {/if} + + +
+

+ {#if isHubOffline} + Servicio de licencias no disponible + {:else} + Acceso suspendido + {/if} +

+

+ {error.message} +

+
+ + +
+ {#if isHubOffline} + El servidor de licencias no está disponible en este momento. Por favor, inténtalo de nuevo + en unos minutos o contacta a soporte si el problema persiste. + {:else if error.type === 'LICENSE_ERROR'} + Tu organización no cuenta con una licencia activa para acceder al sistema. Contacta a tu + administrador o al equipo de soporte para regularizar tu suscripción. + {:else} + No tienes permisos para acceder al sistema. Contacta a tu administrador. + {/if} +
+ + +
+ {#if isHubOffline} + + {/if} + +
+
+
diff --git a/frontend/src/lib/components/session-timeout-warning.svelte b/frontend/src/lib/components/session-timeout-warning.svelte new file mode 100644 index 0000000..9462081 --- /dev/null +++ b/frontend/src/lib/components/session-timeout-warning.svelte @@ -0,0 +1,128 @@ + + + + + + + + + + ⚠️ Sesión por expirar + + + Tu sesión cerrará automáticamente por inactividad en + + {formatTime(remainingSeconds)} + . +
+ ¿Deseas continuar trabajando? +
+
+ + + + + +
+
+
diff --git a/frontend/src/lib/components/sidebar/app-launcher.svelte b/frontend/src/lib/components/sidebar/app-launcher.svelte new file mode 100644 index 0000000..f6d932b --- /dev/null +++ b/frontend/src/lib/components/sidebar/app-launcher.svelte @@ -0,0 +1,81 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + +

Tus aplicaciones

+
+ {#each workspaceAppsStore.apps as app (app.id)} + + {/each} +
+
+
diff --git a/frontend/src/lib/components/sidebar/app-sidebar.svelte b/frontend/src/lib/components/sidebar/app-sidebar.svelte new file mode 100644 index 0000000..c157669 --- /dev/null +++ b/frontend/src/lib/components/sidebar/app-sidebar.svelte @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts new file mode 100644 index 0000000..3dbd257 --- /dev/null +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -0,0 +1,75 @@ +import { + LayoutDashboard, + Settings2, + Users, + Shield, +} from '@lucide/svelte'; + +export type SystemContext = 'fixed_asset' | 'inventory'; + +export interface NavItem { + title: string; + url: string; + permission?: string; + systemContext?: SystemContext; +} + +export interface NavMainItem { + title: string; + url: string; + icon: any; + isActive?: boolean; + permission?: string; + items?: NavItem[]; +} + +/** + * Navegación principal del dashboard. + * Agrega aquí los módulos de tu proyecto. + */ +export function getNavMain(): NavMainItem[] { + return [ + { + title: 'Dashboard', + url: '/dashboard', + icon: LayoutDashboard, + }, + { + title: 'Usuarios', + url: '/dashboard/users', + icon: Users, + }, + { + title: 'Roles y permisos', + url: '/dashboard/roles', + icon: Shield, + }, + { + title: 'Configuración', + url: '/dashboard/settings/general', + icon: Settings2, + }, + ]; +} + +/** + * Datos completos del sidebar (navegación + usuario fallback + proyectos). + * El usuario real se inyecta desde page.data en app-sidebar.svelte. + */ +export function getSidebarData() { + return { + navMain: getNavMain(), + projects: [] as { name: string; url: string; icon: any }[], + user: { + name: '', + email: '', + username: '', + firstName: null, + lastName: null, + displayName: '', + avatarUrl: null, + workspaceAvatarUrl: null, + legacyAvatarUrl: null, + }, + }; +} diff --git a/frontend/src/lib/components/sidebar/nav-main.svelte b/frontend/src/lib/components/sidebar/nav-main.svelte new file mode 100644 index 0000000..293cc07 --- /dev/null +++ b/frontend/src/lib/components/sidebar/nav-main.svelte @@ -0,0 +1,337 @@ + + + + + Anexo-76 + {#if $permissionsRefreshing} + + {/if} + + + {#each visibleItems as item (item.title)} + {#if item.items && item.items.length > 0} + {#if sidebar.state === 'collapsed'} + + + onOpenChange(v, item.title)} + > + + {#snippet child({ props })} +
handleTriggerEnter(item.title)} + onpointerleave={(e) => handleTriggerLeave(e, item.title)} + > + + {#if item.icon} + + {:else} +
+ {/if} + + {item.title} +
+
+ {/snippet} +
+ handleContentEnter(item.title)} + onpointerleave={(e) => handleContentLeave(e, item.title)} + > + +
+ {#if item.icon} + + {:else} +
+ {/if} +
+ + +
+ +
+ {item.title} +
+ + + {#each item.items as subItem (subItem.title)} + + {#snippet child({ props })} + + {subItem.title} + + {/snippet} + + {/each} + +
+
+
+
+ {:else} + + isUrlActive(sub.url))} + class="group/collapsible" + > + {#snippet child({ props })} + + + {#snippet child({ props })} + + {#if item.icon} + + {/if} + {item.title} + + + {/snippet} + + + + {#each item.items as subItem (subItem.title)} + + + {#snippet child({ props })} + + {subItem.title} + + {/snippet} + + + {/each} + + + + {/snippet} + + {/if} + {:else} + + + + {#snippet child({ props })} + + {#if item.icon} + + {/if} + {item.title} + + {/snippet} + + + {/if} + {/each} +
+
diff --git a/frontend/src/lib/components/sidebar/nav-projects.svelte b/frontend/src/lib/components/sidebar/nav-projects.svelte new file mode 100644 index 0000000..0591037 --- /dev/null +++ b/frontend/src/lib/components/sidebar/nav-projects.svelte @@ -0,0 +1,33 @@ + + + + {m['sidebar.management_label']()} + + {#each projects as item (item.name)} + + + {#snippet child({ props })} + + + {item.name} + + {/snippet} + + + {/each} + + diff --git a/frontend/src/lib/components/sidebar/nav-user.svelte b/frontend/src/lib/components/sidebar/nav-user.svelte new file mode 100644 index 0000000..bd73c6b --- /dev/null +++ b/frontend/src/lib/components/sidebar/nav-user.svelte @@ -0,0 +1,285 @@ + + + + + + + {#snippet child({ props })} + + + + {initials} + +
+ {displayName} + {user.email} +
+ +
+ {/snippet} +
+ + +
+ + + {initials} + +
+ {displayName} + {user.email} +
+
+
+ + + + + Account + + + + Billing + + + + Notifications + + + + + + Language: {currentLocale.toUpperCase()} + + + {#if isDarkMode} + + Light Mode + {:else} + + Dark Mode + {/if} + + + {#if tenants.length > 1} + + + {#if switchingTenant} + + + + + {:else} + + {/if} + Cambiar organización + + + + Mis organizaciones + + {#each tenants as tenant (tenant.id)} + switchTenant(tenant.slug)} + > + + {tenant.name} + {#if tenant.slug === currentTenantSlug} + + {/if} + + {/each} + + + {/if} + + + + Log out + + +
+ +
+
+
+
+
diff --git a/frontend/src/lib/components/sidebar/team-switcher.svelte b/frontend/src/lib/components/sidebar/team-switcher.svelte new file mode 100644 index 0000000..547c9c1 --- /dev/null +++ b/frontend/src/lib/components/sidebar/team-switcher.svelte @@ -0,0 +1,209 @@ + + + + + + + {#snippet child({ props })} + +
+ {#if activeCompanyLogoUrl} + {companyStore.activeCompany?.name { + (e.currentTarget as HTMLImageElement).style.display = 'none'; + }} + /> + {:else} + {activeCompanyInitials} + {/if} +
+
+ + {companyStore.activeCompany?.name || 'Seleccionar compañía'} + + {#if companyStore.activeCompany?.rfc} + + {companyStore.activeCompany.rfc} + + {/if} +
+ + + + +
+ {/snippet} +
+ + Tenant + {#if userTenants.length === 0} + + Sin tenant asignado + + {:else} + {#each userTenants as tenant (tenant.id)} + switchTenant(tenant)} + class="cursor-pointer gap-2 p-2" + disabled={switchingTenant} + > +
+ +
+ {tenant.name} + {#if activeTenantPubId === tenant.id} + + {/if} +
+ {/each} + {/if} + + + Mis compañías + + {#if companyStore.loading} + + Cargando... + + {:else if myCompanies.length === 0} + + No tienes compañías disponibles + + {:else} + {#each myCompanies as company, index (company.id)} + companyStore.setActiveCompany(company)} + class="cursor-pointer gap-2 p-2" + > +
+ {company.name.slice(0, 2).toUpperCase()} +
+
+ {company.name} + {#if company.rfc} + {company.rfc} + {/if} +
+ {#if companyStore.activeCompany?.id === company.id} + + {/if} + {#if index < 9} + ⌘{index + 1} + {/if} +
+ {/each} + {/if} +
+
+
+
diff --git a/frontend/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte b/frontend/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte new file mode 100644 index 0000000..a005691 --- /dev/null +++ b/frontend/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte @@ -0,0 +1,18 @@ + + + diff --git a/frontend/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte b/frontend/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte new file mode 100644 index 0000000..a7b0cf7 --- /dev/null +++ b/frontend/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte @@ -0,0 +1,18 @@ + + + diff --git a/frontend/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte b/frontend/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte new file mode 100644 index 0000000..c6fda2b --- /dev/null +++ b/frontend/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte @@ -0,0 +1,30 @@ + + + + + + diff --git a/frontend/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte b/frontend/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte new file mode 100644 index 0000000..2ec67dc --- /dev/null +++ b/frontend/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte b/frontend/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte new file mode 100644 index 0000000..f78b97a --- /dev/null +++ b/frontend/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/frontend/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte b/frontend/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte new file mode 100644 index 0000000..c8fa762 --- /dev/null +++ b/frontend/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/frontend/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte b/frontend/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte new file mode 100644 index 0000000..a64ee76 --- /dev/null +++ b/frontend/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte b/frontend/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte new file mode 100644 index 0000000..7ef2b5f --- /dev/null +++ b/frontend/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte b/frontend/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte new file mode 100644 index 0000000..b22d1d5 --- /dev/null +++ b/frontend/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/components/ui/alert-dialog/index.ts b/frontend/src/lib/components/ui/alert-dialog/index.ts new file mode 100644 index 0000000..98ad2f2 --- /dev/null +++ b/frontend/src/lib/components/ui/alert-dialog/index.ts @@ -0,0 +1,40 @@ +import { AlertDialog } from "bits-ui"; +const AlertDialogPrimitive = AlertDialog; +import Trigger from "./alert-dialog-trigger.svelte"; +import Title from "./alert-dialog-title.svelte"; +import Action from "./alert-dialog-action.svelte"; +import Cancel from "./alert-dialog-cancel.svelte"; +import Footer from "./alert-dialog-footer.svelte"; +import Header from "./alert-dialog-header.svelte"; +import Overlay from "./alert-dialog-overlay.svelte"; +import Content from "./alert-dialog-content.svelte"; +import Description from "./alert-dialog-description.svelte"; + +const Root = AlertDialogPrimitive.Root; +const Portal = AlertDialogPrimitive.Portal; + +export { + Root, + Title, + Action, + Cancel, + Portal, + Footer, + Header, + Trigger, + Overlay, + Content, + Description, + // + Root as AlertDialog, + Title as AlertDialogTitle, + Action as AlertDialogAction, + Cancel as AlertDialogCancel, + Portal as AlertDialogPortal, + Footer as AlertDialogFooter, + Header as AlertDialogHeader, + Trigger as AlertDialogTrigger, + Overlay as AlertDialogOverlay, + Content as AlertDialogContent, + Description as AlertDialogDescription, +}; diff --git a/frontend/src/lib/components/ui/alert/alert-description.svelte b/frontend/src/lib/components/ui/alert/alert-description.svelte new file mode 100644 index 0000000..8b56aed --- /dev/null +++ b/frontend/src/lib/components/ui/alert/alert-description.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/frontend/src/lib/components/ui/alert/alert-title.svelte b/frontend/src/lib/components/ui/alert/alert-title.svelte new file mode 100644 index 0000000..77e45ad --- /dev/null +++ b/frontend/src/lib/components/ui/alert/alert-title.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/frontend/src/lib/components/ui/alert/alert.svelte b/frontend/src/lib/components/ui/alert/alert.svelte new file mode 100644 index 0000000..2b2eff9 --- /dev/null +++ b/frontend/src/lib/components/ui/alert/alert.svelte @@ -0,0 +1,44 @@ + + + + + diff --git a/frontend/src/lib/components/ui/alert/index.ts b/frontend/src/lib/components/ui/alert/index.ts new file mode 100644 index 0000000..97e21b4 --- /dev/null +++ b/frontend/src/lib/components/ui/alert/index.ts @@ -0,0 +1,14 @@ +import Root from "./alert.svelte"; +import Description from "./alert-description.svelte"; +import Title from "./alert-title.svelte"; +export { alertVariants, type AlertVariant } from "./alert.svelte"; + +export { + Root, + Description, + Title, + // + Root as Alert, + Description as AlertDescription, + Title as AlertTitle, +}; diff --git a/frontend/src/lib/components/ui/avatar/avatar-fallback.svelte b/frontend/src/lib/components/ui/avatar/avatar-fallback.svelte new file mode 100644 index 0000000..249d4a4 --- /dev/null +++ b/frontend/src/lib/components/ui/avatar/avatar-fallback.svelte @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/lib/components/ui/avatar/avatar-image.svelte b/frontend/src/lib/components/ui/avatar/avatar-image.svelte new file mode 100644 index 0000000..2bb9db4 --- /dev/null +++ b/frontend/src/lib/components/ui/avatar/avatar-image.svelte @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/lib/components/ui/avatar/avatar.svelte b/frontend/src/lib/components/ui/avatar/avatar.svelte new file mode 100644 index 0000000..e37214d --- /dev/null +++ b/frontend/src/lib/components/ui/avatar/avatar.svelte @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/lib/components/ui/avatar/index.ts b/frontend/src/lib/components/ui/avatar/index.ts new file mode 100644 index 0000000..d06457b --- /dev/null +++ b/frontend/src/lib/components/ui/avatar/index.ts @@ -0,0 +1,13 @@ +import Root from "./avatar.svelte"; +import Image from "./avatar-image.svelte"; +import Fallback from "./avatar-fallback.svelte"; + +export { + Root, + Image, + Fallback, + // + Root as Avatar, + Image as AvatarImage, + Fallback as AvatarFallback, +}; diff --git a/frontend/src/lib/components/ui/badge/badge.svelte b/frontend/src/lib/components/ui/badge/badge.svelte new file mode 100644 index 0000000..bfaa9c5 --- /dev/null +++ b/frontend/src/lib/components/ui/badge/badge.svelte @@ -0,0 +1,50 @@ + + + + + + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/badge/index.ts b/frontend/src/lib/components/ui/badge/index.ts new file mode 100644 index 0000000..64e0aa9 --- /dev/null +++ b/frontend/src/lib/components/ui/badge/index.ts @@ -0,0 +1,2 @@ +export { default as Badge } from "./badge.svelte"; +export { badgeVariants, type BadgeVariant } from "./badge.svelte"; diff --git a/frontend/src/lib/components/ui/breadcrumb/breadcrumb-ellipsis.svelte b/frontend/src/lib/components/ui/breadcrumb/breadcrumb-ellipsis.svelte new file mode 100644 index 0000000..a178cf5 --- /dev/null +++ b/frontend/src/lib/components/ui/breadcrumb/breadcrumb-ellipsis.svelte @@ -0,0 +1,23 @@ + + + diff --git a/frontend/src/lib/components/ui/breadcrumb/breadcrumb-item.svelte b/frontend/src/lib/components/ui/breadcrumb/breadcrumb-item.svelte new file mode 100644 index 0000000..1a84c4c --- /dev/null +++ b/frontend/src/lib/components/ui/breadcrumb/breadcrumb-item.svelte @@ -0,0 +1,20 @@ + + +
  • + {@render children?.()} +
  • diff --git a/frontend/src/lib/components/ui/breadcrumb/breadcrumb-link.svelte b/frontend/src/lib/components/ui/breadcrumb/breadcrumb-link.svelte new file mode 100644 index 0000000..e6bc17d --- /dev/null +++ b/frontend/src/lib/components/ui/breadcrumb/breadcrumb-link.svelte @@ -0,0 +1,31 @@ + + +{#if child} + {@render child({ props: attrs })} +{:else} + + {@render children?.()} + +{/if} diff --git a/frontend/src/lib/components/ui/breadcrumb/breadcrumb-list.svelte b/frontend/src/lib/components/ui/breadcrumb/breadcrumb-list.svelte new file mode 100644 index 0000000..b5458fa --- /dev/null +++ b/frontend/src/lib/components/ui/breadcrumb/breadcrumb-list.svelte @@ -0,0 +1,23 @@ + + +
      + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/breadcrumb/breadcrumb-page.svelte b/frontend/src/lib/components/ui/breadcrumb/breadcrumb-page.svelte new file mode 100644 index 0000000..5fb6979 --- /dev/null +++ b/frontend/src/lib/components/ui/breadcrumb/breadcrumb-page.svelte @@ -0,0 +1,23 @@ + + + + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/breadcrumb/breadcrumb-separator.svelte b/frontend/src/lib/components/ui/breadcrumb/breadcrumb-separator.svelte new file mode 100644 index 0000000..84106a1 --- /dev/null +++ b/frontend/src/lib/components/ui/breadcrumb/breadcrumb-separator.svelte @@ -0,0 +1,27 @@ + + + diff --git a/frontend/src/lib/components/ui/breadcrumb/breadcrumb.svelte b/frontend/src/lib/components/ui/breadcrumb/breadcrumb.svelte new file mode 100644 index 0000000..8f8a3e6 --- /dev/null +++ b/frontend/src/lib/components/ui/breadcrumb/breadcrumb.svelte @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/lib/components/ui/breadcrumb/index.ts b/frontend/src/lib/components/ui/breadcrumb/index.ts new file mode 100644 index 0000000..dc914ec --- /dev/null +++ b/frontend/src/lib/components/ui/breadcrumb/index.ts @@ -0,0 +1,25 @@ +import Root from "./breadcrumb.svelte"; +import Ellipsis from "./breadcrumb-ellipsis.svelte"; +import Item from "./breadcrumb-item.svelte"; +import Separator from "./breadcrumb-separator.svelte"; +import Link from "./breadcrumb-link.svelte"; +import List from "./breadcrumb-list.svelte"; +import Page from "./breadcrumb-page.svelte"; + +export { + Root, + Ellipsis, + Item, + Separator, + Link, + List, + Page, + // + Root as Breadcrumb, + Ellipsis as BreadcrumbEllipsis, + Item as BreadcrumbItem, + Separator as BreadcrumbSeparator, + Link as BreadcrumbLink, + List as BreadcrumbList, + Page as BreadcrumbPage, +}; diff --git a/frontend/src/lib/components/ui/button/button.svelte b/frontend/src/lib/components/ui/button/button.svelte new file mode 100644 index 0000000..2105474 --- /dev/null +++ b/frontend/src/lib/components/ui/button/button.svelte @@ -0,0 +1,82 @@ + + + + +{#if href} + + {@render children?.()} + +{:else} + +{/if} diff --git a/frontend/src/lib/components/ui/button/index.ts b/frontend/src/lib/components/ui/button/index.ts new file mode 100644 index 0000000..fb585d7 --- /dev/null +++ b/frontend/src/lib/components/ui/button/index.ts @@ -0,0 +1,17 @@ +import Root, { + type ButtonProps, + type ButtonSize, + type ButtonVariant, + buttonVariants, +} from "./button.svelte"; + +export { + Root, + type ButtonProps as Props, + // + Root as Button, + buttonVariants, + type ButtonProps, + type ButtonSize, + type ButtonVariant, +}; diff --git a/frontend/src/lib/components/ui/card/card-action.svelte b/frontend/src/lib/components/ui/card/card-action.svelte new file mode 100644 index 0000000..cc36c56 --- /dev/null +++ b/frontend/src/lib/components/ui/card/card-action.svelte @@ -0,0 +1,20 @@ + + +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/card/card-content.svelte b/frontend/src/lib/components/ui/card/card-content.svelte new file mode 100644 index 0000000..bc90b83 --- /dev/null +++ b/frontend/src/lib/components/ui/card/card-content.svelte @@ -0,0 +1,15 @@ + + +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/card/card-description.svelte b/frontend/src/lib/components/ui/card/card-description.svelte new file mode 100644 index 0000000..9b20ac7 --- /dev/null +++ b/frontend/src/lib/components/ui/card/card-description.svelte @@ -0,0 +1,20 @@ + + +

    + {@render children?.()} +

    diff --git a/frontend/src/lib/components/ui/card/card-footer.svelte b/frontend/src/lib/components/ui/card/card-footer.svelte new file mode 100644 index 0000000..cf43353 --- /dev/null +++ b/frontend/src/lib/components/ui/card/card-footer.svelte @@ -0,0 +1,20 @@ + + +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/card/card-header.svelte b/frontend/src/lib/components/ui/card/card-header.svelte new file mode 100644 index 0000000..8a91abb --- /dev/null +++ b/frontend/src/lib/components/ui/card/card-header.svelte @@ -0,0 +1,23 @@ + + +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/card/card-title.svelte b/frontend/src/lib/components/ui/card/card-title.svelte new file mode 100644 index 0000000..22586e6 --- /dev/null +++ b/frontend/src/lib/components/ui/card/card-title.svelte @@ -0,0 +1,20 @@ + + +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/card/card.svelte b/frontend/src/lib/components/ui/card/card.svelte new file mode 100644 index 0000000..99448cc --- /dev/null +++ b/frontend/src/lib/components/ui/card/card.svelte @@ -0,0 +1,23 @@ + + +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/card/index.ts b/frontend/src/lib/components/ui/card/index.ts new file mode 100644 index 0000000..4d3fce4 --- /dev/null +++ b/frontend/src/lib/components/ui/card/index.ts @@ -0,0 +1,25 @@ +import Root from "./card.svelte"; +import Content from "./card-content.svelte"; +import Description from "./card-description.svelte"; +import Footer from "./card-footer.svelte"; +import Header from "./card-header.svelte"; +import Title from "./card-title.svelte"; +import Action from "./card-action.svelte"; + +export { + Root, + Content, + Description, + Footer, + Header, + Title, + Action, + // + Root as Card, + Content as CardContent, + Description as CardDescription, + Footer as CardFooter, + Header as CardHeader, + Title as CardTitle, + Action as CardAction, +}; diff --git a/frontend/src/lib/components/ui/checkbox/checkbox.svelte b/frontend/src/lib/components/ui/checkbox/checkbox.svelte new file mode 100644 index 0000000..0a2b010 --- /dev/null +++ b/frontend/src/lib/components/ui/checkbox/checkbox.svelte @@ -0,0 +1,36 @@ + + + + {#snippet children({ checked, indeterminate })} +
    + {#if checked} + + {:else if indeterminate} + + {/if} +
    + {/snippet} +
    diff --git a/frontend/src/lib/components/ui/checkbox/index.ts b/frontend/src/lib/components/ui/checkbox/index.ts new file mode 100644 index 0000000..6d92d94 --- /dev/null +++ b/frontend/src/lib/components/ui/checkbox/index.ts @@ -0,0 +1,6 @@ +import Root from "./checkbox.svelte"; +export { + Root, + // + Root as Checkbox, +}; diff --git a/frontend/src/lib/components/ui/collapsible/collapsible-content.svelte b/frontend/src/lib/components/ui/collapsible/collapsible-content.svelte new file mode 100644 index 0000000..bdabb55 --- /dev/null +++ b/frontend/src/lib/components/ui/collapsible/collapsible-content.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/components/ui/collapsible/collapsible-trigger.svelte b/frontend/src/lib/components/ui/collapsible/collapsible-trigger.svelte new file mode 100644 index 0000000..ece7ad6 --- /dev/null +++ b/frontend/src/lib/components/ui/collapsible/collapsible-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/components/ui/collapsible/collapsible.svelte b/frontend/src/lib/components/ui/collapsible/collapsible.svelte new file mode 100644 index 0000000..39cdd4e --- /dev/null +++ b/frontend/src/lib/components/ui/collapsible/collapsible.svelte @@ -0,0 +1,11 @@ + + + diff --git a/frontend/src/lib/components/ui/collapsible/index.ts b/frontend/src/lib/components/ui/collapsible/index.ts new file mode 100644 index 0000000..169b479 --- /dev/null +++ b/frontend/src/lib/components/ui/collapsible/index.ts @@ -0,0 +1,13 @@ +import Root from "./collapsible.svelte"; +import Trigger from "./collapsible-trigger.svelte"; +import Content from "./collapsible-content.svelte"; + +export { + Root, + Content, + Trigger, + // + Root as Collapsible, + Content as CollapsibleContent, + Trigger as CollapsibleTrigger, +}; diff --git a/frontend/src/lib/components/ui/context-menu/context-menu-content.svelte b/frontend/src/lib/components/ui/context-menu/context-menu-content.svelte new file mode 100644 index 0000000..896255f --- /dev/null +++ b/frontend/src/lib/components/ui/context-menu/context-menu-content.svelte @@ -0,0 +1,25 @@ + + + + + diff --git a/frontend/src/lib/components/ui/context-menu/context-menu-group.svelte b/frontend/src/lib/components/ui/context-menu/context-menu-group.svelte new file mode 100644 index 0000000..a332ef0 --- /dev/null +++ b/frontend/src/lib/components/ui/context-menu/context-menu-group.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/components/ui/context-menu/context-menu-item.svelte b/frontend/src/lib/components/ui/context-menu/context-menu-item.svelte new file mode 100644 index 0000000..9b08f02 --- /dev/null +++ b/frontend/src/lib/components/ui/context-menu/context-menu-item.svelte @@ -0,0 +1,27 @@ + + + diff --git a/frontend/src/lib/components/ui/context-menu/context-menu-label.svelte b/frontend/src/lib/components/ui/context-menu/context-menu-label.svelte new file mode 100644 index 0000000..f8072cf --- /dev/null +++ b/frontend/src/lib/components/ui/context-menu/context-menu-label.svelte @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/lib/components/ui/context-menu/context-menu-root.svelte b/frontend/src/lib/components/ui/context-menu/context-menu-root.svelte new file mode 100644 index 0000000..e42e969 --- /dev/null +++ b/frontend/src/lib/components/ui/context-menu/context-menu-root.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/components/ui/context-menu/context-menu-separator.svelte b/frontend/src/lib/components/ui/context-menu/context-menu-separator.svelte new file mode 100644 index 0000000..e5fbb87 --- /dev/null +++ b/frontend/src/lib/components/ui/context-menu/context-menu-separator.svelte @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/lib/components/ui/context-menu/context-menu-sub-content.svelte b/frontend/src/lib/components/ui/context-menu/context-menu-sub-content.svelte new file mode 100644 index 0000000..5db5aa4 --- /dev/null +++ b/frontend/src/lib/components/ui/context-menu/context-menu-sub-content.svelte @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/lib/components/ui/context-menu/context-menu-sub-trigger.svelte b/frontend/src/lib/components/ui/context-menu/context-menu-sub-trigger.svelte new file mode 100644 index 0000000..4668f40 --- /dev/null +++ b/frontend/src/lib/components/ui/context-menu/context-menu-sub-trigger.svelte @@ -0,0 +1,27 @@ + + + + {@render children?.()} + + diff --git a/frontend/src/lib/components/ui/context-menu/context-menu-sub.svelte b/frontend/src/lib/components/ui/context-menu/context-menu-sub.svelte new file mode 100644 index 0000000..b8c349b --- /dev/null +++ b/frontend/src/lib/components/ui/context-menu/context-menu-sub.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/components/ui/context-menu/context-menu-trigger.svelte b/frontend/src/lib/components/ui/context-menu/context-menu-trigger.svelte new file mode 100644 index 0000000..5b435d5 --- /dev/null +++ b/frontend/src/lib/components/ui/context-menu/context-menu-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/components/ui/context-menu/index.ts b/frontend/src/lib/components/ui/context-menu/index.ts new file mode 100644 index 0000000..4ac1166 --- /dev/null +++ b/frontend/src/lib/components/ui/context-menu/index.ts @@ -0,0 +1,31 @@ +import { ContextMenu as ContextMenuPrimitive } from "bits-ui"; +import Content from "./context-menu-content.svelte"; +import Group from "./context-menu-group.svelte"; +import Item from "./context-menu-item.svelte"; +import Label from "./context-menu-label.svelte"; +import Separator from "./context-menu-separator.svelte"; +import Sub from "./context-menu-sub.svelte"; +import SubContent from "./context-menu-sub-content.svelte"; +import SubTrigger from "./context-menu-sub-trigger.svelte"; +import Trigger from "./context-menu-trigger.svelte"; +import Root from "./context-menu-root.svelte"; + +const CheckboxItem = ContextMenuPrimitive.CheckboxItem; +const RadioGroup = ContextMenuPrimitive.RadioGroup; +const RadioItem = ContextMenuPrimitive.RadioItem; + +export { + Root, + Trigger, + Content, + Item, + Label, + Separator, + Group, + Sub, + SubTrigger, + SubContent, + CheckboxItem, + RadioGroup, + RadioItem, +}; diff --git a/frontend/src/lib/components/ui/data-table/data-table.svelte.ts b/frontend/src/lib/components/ui/data-table/data-table.svelte.ts new file mode 100644 index 0000000..01f55af --- /dev/null +++ b/frontend/src/lib/components/ui/data-table/data-table.svelte.ts @@ -0,0 +1,142 @@ +import { + type RowData, + type TableOptions, + type TableOptionsResolved, + type TableState, + createTable, +} from "@tanstack/table-core"; + +/** + * Creates a reactive TanStack table object for Svelte. + * @param options Table options to create the table with. + * @returns A reactive table object. + * @example + * ```svelte + * + * + * + * + * {#each table.getHeaderGroups() as headerGroup} + * + * {#each headerGroup.headers as header} + * + * {/each} + * + * {/each} + * + * + *
    + * + *
    + * ``` + */ +export function createSvelteTable(options: TableOptions) { + const resolvedOptions: TableOptionsResolved = mergeObjects( + { + state: {}, + onStateChange() {}, + renderFallbackValue: null, + mergeOptions: ( + defaultOptions: TableOptions, + options: Partial> + ) => { + return mergeObjects(defaultOptions, options); + }, + }, + options + ); + + const table = createTable(resolvedOptions); + // Use JSON parse/stringify to ensure we get a clean, non-proxy initial state object + let state = $state>(JSON.parse(JSON.stringify(table.initialState))); + + function updateOptions() { + table.setOptions((prev) => { + return mergeObjects(prev, options, { + state: mergeObjects(state, options.state || {}), + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onStateChange: (updater: any) => { + if (updater instanceof Function) state = updater(state); + else state = mergeObjects(state, updater); + + options.onStateChange?.(updater); + }, + }); + }); + } + + updateOptions(); + + $effect.pre(() => { + updateOptions(); + }); + + return table; +} + +type MaybeThunk = T | (() => T | null | undefined); +type Intersection = (T extends [infer H, ...infer R] + ? H & Intersection + : unknown) & {}; + +/** + * Lazily merges several objects (or thunks) while preserving + * getter semantics from every source. + * + * Proxy-based to avoid known WebKit recursion issue. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function mergeObjects[]>( + ...sources: Sources +): Intersection<{ [K in keyof Sources]: Sources[K] }> { + const resolve = (src: MaybeThunk): T | undefined => + typeof src === "function" ? (src() ?? undefined) : src; + + const findSourceWithKey = (key: PropertyKey) => { + for (let i = sources.length - 1; i >= 0; i--) { + const obj = resolve(sources[i]); + if (obj && key in obj) return obj; + } + return undefined; + }; + + return new Proxy(Object.create(null), { + get(_, key) { + const src = findSourceWithKey(key); + return src ? src[key as never] : undefined; + }, + + has(_, key) { + return !!findSourceWithKey(key); + }, + + ownKeys(): (string | symbol)[] { + // eslint-disable-next-line svelte/prefer-svelte-reactivity + const all = new Set(); + for (const s of sources) { + const obj = resolve(s); + if (obj) { + for (const k of Reflect.ownKeys(obj) as (string | symbol)[]) { + all.add(k); + } + } + } + return [...all]; + }, + + getOwnPropertyDescriptor(_, key) { + const src = findSourceWithKey(key); + if (!src) return undefined; + return { + configurable: true, + enumerable: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: (src as any)[key], + writable: true, + }; + }, + }) as Intersection<{ [K in keyof Sources]: Sources[K] }>; +} diff --git a/frontend/src/lib/components/ui/data-table/flex-render.svelte b/frontend/src/lib/components/ui/data-table/flex-render.svelte new file mode 100644 index 0000000..ac82a58 --- /dev/null +++ b/frontend/src/lib/components/ui/data-table/flex-render.svelte @@ -0,0 +1,40 @@ + + +{#if typeof content === "string"} + {content} +{:else if content instanceof Function} + + + {@const result = content(context as any)} + {#if result instanceof RenderComponentConfig} + {@const { component: Component, props } = result} + + {:else if result instanceof RenderSnippetConfig} + {@const { snippet, params } = result} + {@render snippet({ ...params, attach })} + {:else} + {result} + {/if} +{/if} diff --git a/frontend/src/lib/components/ui/data-table/index.ts b/frontend/src/lib/components/ui/data-table/index.ts new file mode 100644 index 0000000..5f4e77e --- /dev/null +++ b/frontend/src/lib/components/ui/data-table/index.ts @@ -0,0 +1,3 @@ +export { default as FlexRender } from "./flex-render.svelte"; +export { renderComponent, renderSnippet } from "./render-helpers.js"; +export { createSvelteTable } from "./data-table.svelte.js"; diff --git a/frontend/src/lib/components/ui/data-table/render-helpers.ts b/frontend/src/lib/components/ui/data-table/render-helpers.ts new file mode 100644 index 0000000..fa036d6 --- /dev/null +++ b/frontend/src/lib/components/ui/data-table/render-helpers.ts @@ -0,0 +1,111 @@ +import type { Component, ComponentProps, Snippet } from "svelte"; + +/** + * A helper class to make it easy to identify Svelte components in + * `columnDef.cell` and `columnDef.header` properties. + * + * > NOTE: This class should only be used internally by the adapter. If you're + * reading this and you don't know what this is for, you probably don't need it. + * + * @example + * ```svelte + * {@const result = content(context as any)} + * {#if result instanceof RenderComponentConfig} + * {@const { component: Component, props } = result} + * + * {/if} + * ``` + */ +export class RenderComponentConfig { + component: TComponent; + props: ComponentProps | Record; + constructor( + component: TComponent, + props: ComponentProps | Record = {} + ) { + this.component = component; + this.props = props; + } +} + +/** + * A helper class to make it easy to identify Svelte Snippets in `columnDef.cell` and `columnDef.header` properties. + * + * > NOTE: This class should only be used internally by the adapter. If you're + * reading this and you don't know what this is for, you probably don't need it. + * + * @example + * ```svelte + * {@const result = content(context as any)} + * {#if result instanceof RenderSnippetConfig} + * {@const { snippet, params } = result} + * {@render snippet(params)} + * {/if} + * ``` + */ +export class RenderSnippetConfig { + snippet: Snippet<[TProps]>; + params: TProps; + constructor(snippet: Snippet<[TProps]>, params: TProps) { + this.snippet = snippet; + this.params = params; + } +} + +/** + * A helper function to help create cells from Svelte components through ColumnDef's `cell` and `header` properties. + * + * This is only to be used with Svelte Components - use `renderSnippet` for Svelte Snippets. + * + * @param component A Svelte component + * @param props The props to pass to `component` + * @returns A `RenderComponentConfig` object that helps svelte-table know how to render the header/cell component. + * @example + * ```ts + * // +page.svelte + * const defaultColumns = [ + * columnHelper.accessor('name', { + * header: header => renderComponent(SortHeader, { label: 'Name', header }), + * }), + * columnHelper.accessor('state', { + * header: header => renderComponent(SortHeader, { label: 'State', header }), + * }), + * ] + * ``` + * @see {@link https://tanstack.com/table/latest/docs/guide/column-defs} + */ +export function renderComponent< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + T extends Component, + Props extends ComponentProps, +>(component: T, props: Props = {} as Props) { + return new RenderComponentConfig(component, props); +} + +/** + * A helper function to help create cells from Svelte Snippets through ColumnDef's `cell` and `header` properties. + * + * The snippet must only take one parameter. + * + * This is only to be used with Snippets - use `renderComponent` for Svelte Components. + * + * @param snippet + * @param params + * @returns - A `RenderSnippetConfig` object that helps svelte-table know how to render the header/cell snippet. + * @example + * ```ts + * // +page.svelte + * const defaultColumns = [ + * columnHelper.accessor('name', { + * cell: cell => renderSnippet(nameSnippet, { name: cell.row.name }), + * }), + * columnHelper.accessor('state', { + * cell: cell => renderSnippet(stateSnippet, { state: cell.row.state }), + * }), + * ] + * ``` + * @see {@link https://tanstack.com/table/latest/docs/guide/column-defs} + */ +export function renderSnippet(snippet: Snippet<[TProps]>, params: TProps = {} as TProps) { + return new RenderSnippetConfig(snippet, params); +} diff --git a/frontend/src/lib/components/ui/date-input-dmy.svelte b/frontend/src/lib/components/ui/date-input-dmy.svelte new file mode 100644 index 0000000..da994e4 --- /dev/null +++ b/frontend/src/lib/components/ui/date-input-dmy.svelte @@ -0,0 +1,129 @@ + + +
    + + + + + + +
    diff --git a/frontend/src/lib/components/ui/dialog/dialog-close.svelte b/frontend/src/lib/components/ui/dialog/dialog-close.svelte new file mode 100644 index 0000000..840b2f6 --- /dev/null +++ b/frontend/src/lib/components/ui/dialog/dialog-close.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/components/ui/dialog/dialog-content.svelte b/frontend/src/lib/components/ui/dialog/dialog-content.svelte new file mode 100644 index 0000000..25cb873 --- /dev/null +++ b/frontend/src/lib/components/ui/dialog/dialog-content.svelte @@ -0,0 +1,46 @@ + + + + + + {@render children?.()} + {#if showCloseButton} + + + Close + + {/if} + + diff --git a/frontend/src/lib/components/ui/dialog/dialog-description.svelte b/frontend/src/lib/components/ui/dialog/dialog-description.svelte new file mode 100644 index 0000000..3845023 --- /dev/null +++ b/frontend/src/lib/components/ui/dialog/dialog-description.svelte @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/lib/components/ui/dialog/dialog-footer.svelte b/frontend/src/lib/components/ui/dialog/dialog-footer.svelte new file mode 100644 index 0000000..e7ff446 --- /dev/null +++ b/frontend/src/lib/components/ui/dialog/dialog-footer.svelte @@ -0,0 +1,20 @@ + + +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/dialog/dialog-header.svelte b/frontend/src/lib/components/ui/dialog/dialog-header.svelte new file mode 100644 index 0000000..fc90cd9 --- /dev/null +++ b/frontend/src/lib/components/ui/dialog/dialog-header.svelte @@ -0,0 +1,20 @@ + + +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/dialog/dialog-overlay.svelte b/frontend/src/lib/components/ui/dialog/dialog-overlay.svelte new file mode 100644 index 0000000..f81ad83 --- /dev/null +++ b/frontend/src/lib/components/ui/dialog/dialog-overlay.svelte @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/lib/components/ui/dialog/dialog-title.svelte b/frontend/src/lib/components/ui/dialog/dialog-title.svelte new file mode 100644 index 0000000..067e55e --- /dev/null +++ b/frontend/src/lib/components/ui/dialog/dialog-title.svelte @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/lib/components/ui/dialog/dialog-trigger.svelte b/frontend/src/lib/components/ui/dialog/dialog-trigger.svelte new file mode 100644 index 0000000..9d1e801 --- /dev/null +++ b/frontend/src/lib/components/ui/dialog/dialog-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/components/ui/dialog/index.ts b/frontend/src/lib/components/ui/dialog/index.ts new file mode 100644 index 0000000..80edaf2 --- /dev/null +++ b/frontend/src/lib/components/ui/dialog/index.ts @@ -0,0 +1,37 @@ +import { Dialog as DialogPrimitive } from "bits-ui"; + +import Title from "./dialog-title.svelte"; +import Footer from "./dialog-footer.svelte"; +import Header from "./dialog-header.svelte"; +import Overlay from "./dialog-overlay.svelte"; +import Content from "./dialog-content.svelte"; +import Description from "./dialog-description.svelte"; +import Trigger from "./dialog-trigger.svelte"; +import Close from "./dialog-close.svelte"; + +const Root = DialogPrimitive?.Root ?? (class {} as any); +const Portal = DialogPrimitive?.Portal ?? (class {} as any); + +export { + Root, + Title, + Portal, + Footer, + Header, + Trigger, + Overlay, + Content, + Description, + Close, + // + Root as Dialog, + Title as DialogTitle, + Portal as DialogPortal, + Footer as DialogFooter, + Header as DialogHeader, + Trigger as DialogTrigger, + Overlay as DialogOverlay, + Content as DialogContent, + Description as DialogDescription, + Close as DialogClose, +}; diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte new file mode 100644 index 0000000..e03f949 --- /dev/null +++ b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte @@ -0,0 +1,41 @@ + + + + {#snippet children({ checked, indeterminate })} + + {#if indeterminate} + + {:else} + + {/if} + + {@render childrenProp?.()} + {/snippet} + diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte new file mode 100644 index 0000000..907ef73 --- /dev/null +++ b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte @@ -0,0 +1,27 @@ + + + + + diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte new file mode 100644 index 0000000..48d14a9 --- /dev/null +++ b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte new file mode 100644 index 0000000..aca1f7b --- /dev/null +++ b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte new file mode 100644 index 0000000..64bb283 --- /dev/null +++ b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte @@ -0,0 +1,27 @@ + + + diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte new file mode 100644 index 0000000..f72e477 --- /dev/null +++ b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte @@ -0,0 +1,24 @@ + + +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte new file mode 100644 index 0000000..189aef4 --- /dev/null +++ b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte @@ -0,0 +1,16 @@ + + + diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte new file mode 100644 index 0000000..513170a --- /dev/null +++ b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte @@ -0,0 +1,31 @@ + + + + {#snippet children({ checked })} + + {#if checked} + + {/if} + + {@render childrenProp?.({ checked })} + {/snippet} + diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-root.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-root.svelte new file mode 100644 index 0000000..8fe2aca --- /dev/null +++ b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-root.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte new file mode 100644 index 0000000..90f1b6f --- /dev/null +++ b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte new file mode 100644 index 0000000..6974947 --- /dev/null +++ b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte new file mode 100644 index 0000000..10e14ca --- /dev/null +++ b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte new file mode 100644 index 0000000..f9b286a --- /dev/null +++ b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte @@ -0,0 +1,29 @@ + + + + {@render children?.()} + + diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte new file mode 100644 index 0000000..276ac39 --- /dev/null +++ b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte new file mode 100644 index 0000000..cb05344 --- /dev/null +++ b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/components/ui/dropdown-menu/index.ts b/frontend/src/lib/components/ui/dropdown-menu/index.ts new file mode 100644 index 0000000..9ac1bdd --- /dev/null +++ b/frontend/src/lib/components/ui/dropdown-menu/index.ts @@ -0,0 +1,49 @@ +// import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui"; +import CheckboxItem from "./dropdown-menu-checkbox-item.svelte"; +import Content from "./dropdown-menu-content.svelte"; +import Group from "./dropdown-menu-group.svelte"; +import Item from "./dropdown-menu-item.svelte"; +import Label from "./dropdown-menu-label.svelte"; +import RadioGroup from "./dropdown-menu-radio-group.svelte"; +import RadioItem from "./dropdown-menu-radio-item.svelte"; +import Separator from "./dropdown-menu-separator.svelte"; +import Shortcut from "./dropdown-menu-shortcut.svelte"; +import Trigger from "./dropdown-menu-trigger.svelte"; +import SubContent from "./dropdown-menu-sub-content.svelte"; +import SubTrigger from "./dropdown-menu-sub-trigger.svelte"; +import GroupHeading from "./dropdown-menu-group-heading.svelte"; +import Sub from "./dropdown-menu-sub.svelte"; +import Root from "./dropdown-menu-root.svelte"; + +export { + CheckboxItem, + Content, + Root as DropdownMenu, + CheckboxItem as DropdownMenuCheckboxItem, + Content as DropdownMenuContent, + Group as DropdownMenuGroup, + Item as DropdownMenuItem, + Label as DropdownMenuLabel, + RadioGroup as DropdownMenuRadioGroup, + RadioItem as DropdownMenuRadioItem, + Separator as DropdownMenuSeparator, + Shortcut as DropdownMenuShortcut, + Sub as DropdownMenuSub, + SubContent as DropdownMenuSubContent, + SubTrigger as DropdownMenuSubTrigger, + Trigger as DropdownMenuTrigger, + GroupHeading as DropdownMenuGroupHeading, + Group, + GroupHeading, + Item, + Label, + RadioGroup, + RadioItem, + Root, + Separator, + Shortcut, + Sub, + SubContent, + SubTrigger, + Trigger, +}; diff --git a/frontend/src/lib/components/ui/error-panel-notice.svelte b/frontend/src/lib/components/ui/error-panel-notice.svelte new file mode 100644 index 0000000..996f4db --- /dev/null +++ b/frontend/src/lib/components/ui/error-panel-notice.svelte @@ -0,0 +1,203 @@ + + +{#if open} +
    + +
    + +

    {title}

    + + + + +
    + + {#if expanded && rows.length > 0} +
    + + + + + + + {#if dismissibleRows && onDismissRow} + + {/if} + + + + {#each rows as row, i (i)} + + + + + {#if dismissibleRows && onDismissRow} + + {/if} + + {/each} + +
    + {labels.columnType} + + {labels.columnField} + + {labels.columnMessage} +
    + + + {row.field || labels.emptyField} + + {row.message} + + +
    +
    + {/if} +
    +{/if} diff --git a/frontend/src/lib/components/ui/field/field-content.svelte b/frontend/src/lib/components/ui/field/field-content.svelte new file mode 100644 index 0000000..1b6535b --- /dev/null +++ b/frontend/src/lib/components/ui/field/field-content.svelte @@ -0,0 +1,20 @@ + + +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/field/field-description.svelte b/frontend/src/lib/components/ui/field/field-description.svelte new file mode 100644 index 0000000..4c147fd --- /dev/null +++ b/frontend/src/lib/components/ui/field/field-description.svelte @@ -0,0 +1,25 @@ + + +

    a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4", + className + )} + {...restProps} +> + {@render children?.()} +

    diff --git a/frontend/src/lib/components/ui/field/field-error.svelte b/frontend/src/lib/components/ui/field/field-error.svelte new file mode 100644 index 0000000..6892811 --- /dev/null +++ b/frontend/src/lib/components/ui/field/field-error.svelte @@ -0,0 +1,58 @@ + + +{#if hasContent} + +{/if} diff --git a/frontend/src/lib/components/ui/field/field-group.svelte b/frontend/src/lib/components/ui/field/field-group.svelte new file mode 100644 index 0000000..e685427 --- /dev/null +++ b/frontend/src/lib/components/ui/field/field-group.svelte @@ -0,0 +1,23 @@ + + +
    [data-slot=field-group]]:gap-4", + className + )} + {...restProps} +> + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/field/field-label.svelte b/frontend/src/lib/components/ui/field/field-label.svelte new file mode 100644 index 0000000..2ee431a --- /dev/null +++ b/frontend/src/lib/components/ui/field/field-label.svelte @@ -0,0 +1,26 @@ + + + diff --git a/frontend/src/lib/components/ui/field/field-legend.svelte b/frontend/src/lib/components/ui/field/field-legend.svelte new file mode 100644 index 0000000..3f1c50f --- /dev/null +++ b/frontend/src/lib/components/ui/field/field-legend.svelte @@ -0,0 +1,29 @@ + + + + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/field/field-separator.svelte b/frontend/src/lib/components/ui/field/field-separator.svelte new file mode 100644 index 0000000..12bcb77 --- /dev/null +++ b/frontend/src/lib/components/ui/field/field-separator.svelte @@ -0,0 +1,38 @@ + + +
    + + {#if children} + + {@render children()} + + {/if} +
    diff --git a/frontend/src/lib/components/ui/field/field-set.svelte b/frontend/src/lib/components/ui/field/field-set.svelte new file mode 100644 index 0000000..1d8e233 --- /dev/null +++ b/frontend/src/lib/components/ui/field/field-set.svelte @@ -0,0 +1,24 @@ + + +
    [data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", + className + )} + {...restProps} +> + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/field/field-title.svelte b/frontend/src/lib/components/ui/field/field-title.svelte new file mode 100644 index 0000000..4230536 --- /dev/null +++ b/frontend/src/lib/components/ui/field/field-title.svelte @@ -0,0 +1,23 @@ + + +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/field/field.svelte b/frontend/src/lib/components/ui/field/field.svelte new file mode 100644 index 0000000..981cb70 --- /dev/null +++ b/frontend/src/lib/components/ui/field/field.svelte @@ -0,0 +1,53 @@ + + + + +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/field/index.ts b/frontend/src/lib/components/ui/field/index.ts new file mode 100644 index 0000000..a644a95 --- /dev/null +++ b/frontend/src/lib/components/ui/field/index.ts @@ -0,0 +1,33 @@ +import Field from "./field.svelte"; +import Set from "./field-set.svelte"; +import Legend from "./field-legend.svelte"; +import Group from "./field-group.svelte"; +import Content from "./field-content.svelte"; +import Label from "./field-label.svelte"; +import Title from "./field-title.svelte"; +import Description from "./field-description.svelte"; +import Separator from "./field-separator.svelte"; +import Error from "./field-error.svelte"; + +export { + Field, + Set, + Legend, + Group, + Content, + Label, + Title, + Description, + Separator, + Error, + // + Set as FieldSet, + Legend as FieldLegend, + Group as FieldGroup, + Content as FieldContent, + Label as FieldLabel, + Title as FieldTitle, + Description as FieldDescription, + Separator as FieldSeparator, + Error as FieldError, +}; diff --git a/frontend/src/lib/components/ui/floating-inline-notice.svelte b/frontend/src/lib/components/ui/floating-inline-notice.svelte new file mode 100644 index 0000000..d0eb9f5 --- /dev/null +++ b/frontend/src/lib/components/ui/floating-inline-notice.svelte @@ -0,0 +1,73 @@ + + +{#if open && message} +
    + +

    + {message} +

    + +
    +{/if} diff --git a/frontend/src/lib/components/ui/icons/FolderIcon.svelte b/frontend/src/lib/components/ui/icons/FolderIcon.svelte new file mode 100644 index 0000000..beada7c --- /dev/null +++ b/frontend/src/lib/components/ui/icons/FolderIcon.svelte @@ -0,0 +1,6 @@ + + + + diff --git a/frontend/src/lib/components/ui/input/file-picker-input.svelte b/frontend/src/lib/components/ui/input/file-picker-input.svelte new file mode 100644 index 0000000..36d5f19 --- /dev/null +++ b/frontend/src/lib/components/ui/input/file-picker-input.svelte @@ -0,0 +1,102 @@ + + + +
    + + buttonEl?.focus()} + /> + + + + + + +
    diff --git a/frontend/src/lib/components/ui/input/index.ts b/frontend/src/lib/components/ui/input/index.ts new file mode 100644 index 0000000..d73ab5b --- /dev/null +++ b/frontend/src/lib/components/ui/input/index.ts @@ -0,0 +1,9 @@ +import Root from "./input.svelte"; +import FilePickerInput from "./file-picker-input.svelte"; + +export { + Root, + // + Root as Input, + FilePickerInput, +}; diff --git a/frontend/src/lib/components/ui/input/input.svelte b/frontend/src/lib/components/ui/input/input.svelte new file mode 100644 index 0000000..1f394e3 --- /dev/null +++ b/frontend/src/lib/components/ui/input/input.svelte @@ -0,0 +1,91 @@ + + +{#if type === "file"} + +{:else if isDateInput} +
    + + +
    +{:else} + +{/if} diff --git a/frontend/src/lib/components/ui/label/index.ts b/frontend/src/lib/components/ui/label/index.ts new file mode 100644 index 0000000..8bfca0b --- /dev/null +++ b/frontend/src/lib/components/ui/label/index.ts @@ -0,0 +1,7 @@ +import Root from "./label.svelte"; + +export { + Root, + // + Root as Label, +}; diff --git a/frontend/src/lib/components/ui/label/label.svelte b/frontend/src/lib/components/ui/label/label.svelte new file mode 100644 index 0000000..d0afda3 --- /dev/null +++ b/frontend/src/lib/components/ui/label/label.svelte @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/lib/components/ui/progress/index.ts b/frontend/src/lib/components/ui/progress/index.ts new file mode 100644 index 0000000..0477d4f --- /dev/null +++ b/frontend/src/lib/components/ui/progress/index.ts @@ -0,0 +1,2 @@ + +export { default as Progress } from "./progress.svelte"; diff --git a/frontend/src/lib/components/ui/progress/progress.svelte b/frontend/src/lib/components/ui/progress/progress.svelte new file mode 100644 index 0000000..9e6fac9 --- /dev/null +++ b/frontend/src/lib/components/ui/progress/progress.svelte @@ -0,0 +1,27 @@ + + +
    +
    +
    diff --git a/frontend/src/lib/components/ui/radio-group/index.ts b/frontend/src/lib/components/ui/radio-group/index.ts new file mode 100644 index 0000000..90b33fe --- /dev/null +++ b/frontend/src/lib/components/ui/radio-group/index.ts @@ -0,0 +1,10 @@ +import Root from "./radio-group.svelte"; +import Item from "./radio-group-item.svelte"; + +export { + Root, + Item, + // + Root as RadioGroup, + Item as RadioGroupItem, +}; diff --git a/frontend/src/lib/components/ui/radio-group/radio-group-item.svelte b/frontend/src/lib/components/ui/radio-group/radio-group-item.svelte new file mode 100644 index 0000000..f0813db --- /dev/null +++ b/frontend/src/lib/components/ui/radio-group/radio-group-item.svelte @@ -0,0 +1,31 @@ + + + + {#snippet children({ checked })} +
    + {#if checked} + + {/if} +
    + {/snippet} +
    diff --git a/frontend/src/lib/components/ui/radio-group/radio-group.svelte b/frontend/src/lib/components/ui/radio-group/radio-group.svelte new file mode 100644 index 0000000..da2912b --- /dev/null +++ b/frontend/src/lib/components/ui/radio-group/radio-group.svelte @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/lib/components/ui/select/index.ts b/frontend/src/lib/components/ui/select/index.ts new file mode 100644 index 0000000..8fc3465 --- /dev/null +++ b/frontend/src/lib/components/ui/select/index.ts @@ -0,0 +1,46 @@ +import { Select as SelectPrimitive } from "bits-ui"; + +import Content from "./select-content.svelte"; +import Item from "./select-item.svelte"; +import Label from "./select-label.svelte"; +import Root from "./select-root.svelte"; +import Trigger from "./select-trigger.svelte"; +import Separator from "./select-separator.svelte"; +import ScrollUpButton from "./select-scroll-up-button.svelte"; +import ScrollDownButton from "./select-scroll-down-button.svelte"; +import GroupHeading from "./select-group-heading.svelte"; +import SearchableSelect from "./select-searchable.svelte"; + +const Group = SelectPrimitive.Group; +const Input = SelectPrimitive.Input; +const Value = SelectPrimitive.Value; + +export { + Root, + Group, + Input, + Label, + Item, + Value, + Content, + Trigger, + Separator, + ScrollUpButton, + ScrollDownButton, + GroupHeading, + SearchableSelect, + // + Root as Select, + Group as SelectGroup, + Input as SelectInput, + Label as SelectLabel, + Item as SelectItem, + Value as SelectValue, + Content as SelectContent, + Trigger as SelectTrigger, + Separator as SelectSeparator, + ScrollUpButton as SelectScrollUpButton, + ScrollDownButton as SelectScrollDownButton, + GroupHeading as SelectGroupHeading, + SearchableSelect as SelectSearchable, +}; diff --git a/frontend/src/lib/components/ui/select/select-content.svelte b/frontend/src/lib/components/ui/select/select-content.svelte new file mode 100644 index 0000000..eadcdf8 --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-content.svelte @@ -0,0 +1,176 @@ + + + + + + + {#if searchable} +
    + updateQuery((event.currentTarget as HTMLInputElement).value)} + onclick={(e) => e.stopPropagation()} + onkeydown={handleSearchInputKeydown} + /> +
    + {/if} + + + {@render children?.()} + + +
    +
    diff --git a/frontend/src/lib/components/ui/select/select-group-heading.svelte b/frontend/src/lib/components/ui/select/select-group-heading.svelte new file mode 100644 index 0000000..1fab5f0 --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-group-heading.svelte @@ -0,0 +1,21 @@ + + + + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/select/select-group.svelte b/frontend/src/lib/components/ui/select/select-group.svelte new file mode 100644 index 0000000..5454fdb --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-group.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/components/ui/select/select-item.svelte b/frontend/src/lib/components/ui/select/select-item.svelte new file mode 100644 index 0000000..2d4ba2e --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-item.svelte @@ -0,0 +1,95 @@ + + +{#if isVisible()} + + {#snippet children({ selected, highlighted })} + + {#if selected} + + {/if} + + {#if childrenProp} + {@render childrenProp({ selected, highlighted })} + {:else} + {label || value} + {/if} + {/snippet} + +{/if} diff --git a/frontend/src/lib/components/ui/select/select-label.svelte b/frontend/src/lib/components/ui/select/select-label.svelte new file mode 100644 index 0000000..4696025 --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-label.svelte @@ -0,0 +1,20 @@ + + +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/select/select-root.svelte b/frontend/src/lib/components/ui/select/select-root.svelte new file mode 100644 index 0000000..7ff8308 --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-root.svelte @@ -0,0 +1,78 @@ + + + + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/select/select-scroll-down-button.svelte b/frontend/src/lib/components/ui/select/select-scroll-down-button.svelte new file mode 100644 index 0000000..3629205 --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-scroll-down-button.svelte @@ -0,0 +1,20 @@ + + + + + diff --git a/frontend/src/lib/components/ui/select/select-scroll-up-button.svelte b/frontend/src/lib/components/ui/select/select-scroll-up-button.svelte new file mode 100644 index 0000000..1aa2300 --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-scroll-up-button.svelte @@ -0,0 +1,20 @@ + + + + + diff --git a/frontend/src/lib/components/ui/select/select-search-context.ts b/frontend/src/lib/components/ui/select/select-search-context.ts new file mode 100644 index 0000000..ff395b5 --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-search-context.ts @@ -0,0 +1,16 @@ +import type { Writable } from "svelte/store"; + +export const selectSearchContextKey = Symbol("select-search"); + +export type SelectSearchContext = { + query: Writable; + open: Writable; + setOpen: (next: boolean) => void; + /** Nodo del trigger para devolver foco al cerrar (bits-ui bloquea onCloseAutoFocus por defecto). */ + triggerRef: Writable; + /** Valor actual del Select (single: string; multiple: string[]). */ + selectedValue: Writable; + selectionType: Writable<'single' | 'multiple'>; + /** Limpia la selección (single → ''; multiple → []). */ + clearValue: () => void; +}; diff --git a/frontend/src/lib/components/ui/select/select-searchable.svelte b/frontend/src/lib/components/ui/select/select-searchable.svelte new file mode 100644 index 0000000..78dd8c0 --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-searchable.svelte @@ -0,0 +1,197 @@ + + + +
    +
    + +
    + {#if value && !disabled} + + {/if} + + + +
    +
    + + + + {#if filteredItems.length === 0} +
    + {emptyMessage} +
    + {:else} + {#each filteredItems as item (`${item.value}\u0000${item.label}`)} + { + // No tratar value '' (p. ej. «Todos los módulos») como «reclic para deseleccionar»: + // si no, al volver a elegir Todos con el filtro ya vacío se cancela el clic. + if (item.value !== '' && item.value === value) { + e.preventDefault(); + e.stopImmediatePropagation(); + handleClear(e); + open = false; + } + }} + class={cn( + 'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50' + )} + > + {item.label} + + {/each} + {/if} +
    +
    +
    +
    diff --git a/frontend/src/lib/components/ui/select/select-separator.svelte b/frontend/src/lib/components/ui/select/select-separator.svelte new file mode 100644 index 0000000..0eac3eb --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-separator.svelte @@ -0,0 +1,18 @@ + + + diff --git a/frontend/src/lib/components/ui/select/select-trigger.svelte b/frontend/src/lib/components/ui/select/select-trigger.svelte new file mode 100644 index 0000000..d19b8d0 --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-trigger.svelte @@ -0,0 +1,49 @@ + + + + {@render children?.()} + + diff --git a/frontend/src/lib/components/ui/separator/index.ts b/frontend/src/lib/components/ui/separator/index.ts new file mode 100644 index 0000000..82442d2 --- /dev/null +++ b/frontend/src/lib/components/ui/separator/index.ts @@ -0,0 +1,7 @@ +import Root from "./separator.svelte"; + +export { + Root, + // + Root as Separator, +}; diff --git a/frontend/src/lib/components/ui/separator/separator.svelte b/frontend/src/lib/components/ui/separator/separator.svelte new file mode 100644 index 0000000..89b2695 --- /dev/null +++ b/frontend/src/lib/components/ui/separator/separator.svelte @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/lib/components/ui/sheet/index.ts b/frontend/src/lib/components/ui/sheet/index.ts new file mode 100644 index 0000000..2c191ac --- /dev/null +++ b/frontend/src/lib/components/ui/sheet/index.ts @@ -0,0 +1,37 @@ +import { Dialog as DialogPrimitive } from "bits-ui"; +const SheetPrimitive = DialogPrimitive; +import Trigger from "./sheet-trigger.svelte"; +import Close from "./sheet-close.svelte"; +import Overlay from "./sheet-overlay.svelte"; +import Content from "./sheet-content.svelte"; +import Header from "./sheet-header.svelte"; +import Footer from "./sheet-footer.svelte"; +import Title from "./sheet-title.svelte"; +import Description from "./sheet-description.svelte"; + +const Root = SheetPrimitive.Root; +const Portal = SheetPrimitive.Portal; + +export { + Root, + Close, + Trigger, + Portal, + Overlay, + Content, + Header, + Footer, + Title, + Description, + // + Root as Sheet, + Close as SheetClose, + Trigger as SheetTrigger, + Portal as SheetPortal, + Overlay as SheetOverlay, + Content as SheetContent, + Header as SheetHeader, + Footer as SheetFooter, + Title as SheetTitle, + Description as SheetDescription, +}; diff --git a/frontend/src/lib/components/ui/sheet/sheet-close.svelte b/frontend/src/lib/components/ui/sheet/sheet-close.svelte new file mode 100644 index 0000000..ae382c1 --- /dev/null +++ b/frontend/src/lib/components/ui/sheet/sheet-close.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/components/ui/sheet/sheet-content.svelte b/frontend/src/lib/components/ui/sheet/sheet-content.svelte new file mode 100644 index 0000000..4f45858 --- /dev/null +++ b/frontend/src/lib/components/ui/sheet/sheet-content.svelte @@ -0,0 +1,60 @@ + + + + + + + + {@render children?.()} + + + Close + + + diff --git a/frontend/src/lib/components/ui/sheet/sheet-description.svelte b/frontend/src/lib/components/ui/sheet/sheet-description.svelte new file mode 100644 index 0000000..333b17a --- /dev/null +++ b/frontend/src/lib/components/ui/sheet/sheet-description.svelte @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/lib/components/ui/sheet/sheet-footer.svelte b/frontend/src/lib/components/ui/sheet/sheet-footer.svelte new file mode 100644 index 0000000..dd9ed84 --- /dev/null +++ b/frontend/src/lib/components/ui/sheet/sheet-footer.svelte @@ -0,0 +1,20 @@ + + +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/sheet/sheet-header.svelte b/frontend/src/lib/components/ui/sheet/sheet-header.svelte new file mode 100644 index 0000000..757a6a5 --- /dev/null +++ b/frontend/src/lib/components/ui/sheet/sheet-header.svelte @@ -0,0 +1,20 @@ + + +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/sheet/sheet-overlay.svelte b/frontend/src/lib/components/ui/sheet/sheet-overlay.svelte new file mode 100644 index 0000000..345e197 --- /dev/null +++ b/frontend/src/lib/components/ui/sheet/sheet-overlay.svelte @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/lib/components/ui/sheet/sheet-title.svelte b/frontend/src/lib/components/ui/sheet/sheet-title.svelte new file mode 100644 index 0000000..9fda327 --- /dev/null +++ b/frontend/src/lib/components/ui/sheet/sheet-title.svelte @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/lib/components/ui/sheet/sheet-trigger.svelte b/frontend/src/lib/components/ui/sheet/sheet-trigger.svelte new file mode 100644 index 0000000..e266975 --- /dev/null +++ b/frontend/src/lib/components/ui/sheet/sheet-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/components/ui/sidebar/constants.ts b/frontend/src/lib/components/ui/sidebar/constants.ts new file mode 100644 index 0000000..4de4435 --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/constants.ts @@ -0,0 +1,6 @@ +export const SIDEBAR_COOKIE_NAME = "sidebar:state"; +export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; +export const SIDEBAR_WIDTH = "16rem"; +export const SIDEBAR_WIDTH_MOBILE = "18rem"; +export const SIDEBAR_WIDTH_ICON = "3rem"; +export const SIDEBAR_KEYBOARD_SHORTCUT = "b"; diff --git a/frontend/src/lib/components/ui/sidebar/context.svelte.ts b/frontend/src/lib/components/ui/sidebar/context.svelte.ts new file mode 100644 index 0000000..15248ad --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/context.svelte.ts @@ -0,0 +1,81 @@ +import { IsMobile } from "$lib/hooks/is-mobile.svelte.js"; +import { getContext, setContext } from "svelte"; +import { SIDEBAR_KEYBOARD_SHORTCUT } from "./constants.js"; + +type Getter = () => T; + +export type SidebarStateProps = { + /** + * A getter function that returns the current open state of the sidebar. + * We use a getter function here to support `bind:open` on the `Sidebar.Provider` + * component. + */ + open: Getter; + + /** + * A function that sets the open state of the sidebar. To support `bind:open`, we need + * a source of truth for changing the open state to ensure it will be synced throughout + * the sub-components and any `bind:` references. + */ + setOpen: (open: boolean) => void; +}; + +class SidebarState { + readonly props: SidebarStateProps; + open = $derived.by(() => this.props.open()); + openMobile = $state(false); + setOpen: SidebarStateProps["setOpen"]; + #isMobile: IsMobile; + state = $derived.by(() => (this.open ? "expanded" : "collapsed")); + + constructor(props: SidebarStateProps) { + this.setOpen = props.setOpen; + this.#isMobile = new IsMobile(); + this.props = props; + } + + // Convenience getter for checking if the sidebar is mobile + // without this, we would need to use `sidebar.isMobile.current` everywhere + get isMobile() { + return this.#isMobile.current; + } + + // Event handler to apply to the `` + handleShortcutKeydown = (e: KeyboardEvent) => { + if (e.key === SIDEBAR_KEYBOARD_SHORTCUT && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + this.toggle(); + } + }; + + setOpenMobile = (value: boolean) => { + this.openMobile = value; + }; + + toggle = () => { + return this.#isMobile.current + ? (this.openMobile = !this.openMobile) + : this.setOpen(!this.open); + }; +} + +const SYMBOL_KEY = "scn-sidebar"; + +/** + * Instantiates a new `SidebarState` instance and sets it in the context. + * + * @param props The constructor props for the `SidebarState` class. + * @returns The `SidebarState` instance. + */ +export function setSidebar(props: SidebarStateProps): SidebarState { + return setContext(Symbol.for(SYMBOL_KEY), new SidebarState(props)); +} + +/** + * Retrieves the `SidebarState` instance from the context. This is a class instance, + * so you cannot destructure it. + * @returns The `SidebarState` instance. + */ +export function useSidebar(): SidebarState { + return getContext(Symbol.for(SYMBOL_KEY)); +} diff --git a/frontend/src/lib/components/ui/sidebar/index.ts b/frontend/src/lib/components/ui/sidebar/index.ts new file mode 100644 index 0000000..318a341 --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/index.ts @@ -0,0 +1,75 @@ +import { useSidebar } from "./context.svelte.js"; +import Content from "./sidebar-content.svelte"; +import Footer from "./sidebar-footer.svelte"; +import GroupAction from "./sidebar-group-action.svelte"; +import GroupContent from "./sidebar-group-content.svelte"; +import GroupLabel from "./sidebar-group-label.svelte"; +import Group from "./sidebar-group.svelte"; +import Header from "./sidebar-header.svelte"; +import Input from "./sidebar-input.svelte"; +import Inset from "./sidebar-inset.svelte"; +import MenuAction from "./sidebar-menu-action.svelte"; +import MenuBadge from "./sidebar-menu-badge.svelte"; +import MenuButton from "./sidebar-menu-button.svelte"; +import MenuItem from "./sidebar-menu-item.svelte"; +import MenuSkeleton from "./sidebar-menu-skeleton.svelte"; +import MenuSubButton from "./sidebar-menu-sub-button.svelte"; +import MenuSubItem from "./sidebar-menu-sub-item.svelte"; +import MenuSub from "./sidebar-menu-sub.svelte"; +import Menu from "./sidebar-menu.svelte"; +import Provider from "./sidebar-provider.svelte"; +import Rail from "./sidebar-rail.svelte"; +import Separator from "./sidebar-separator.svelte"; +import Trigger from "./sidebar-trigger.svelte"; +import Root from "./sidebar.svelte"; + +export { + Content, + Footer, + Group, + GroupAction, + GroupContent, + GroupLabel, + Header, + Input, + Inset, + Menu, + MenuAction, + MenuBadge, + MenuButton, + MenuItem, + MenuSkeleton, + MenuSub, + MenuSubButton, + MenuSubItem, + Provider, + Rail, + Root, + Separator, + // + Root as Sidebar, + Content as SidebarContent, + Footer as SidebarFooter, + Group as SidebarGroup, + GroupAction as SidebarGroupAction, + GroupContent as SidebarGroupContent, + GroupLabel as SidebarGroupLabel, + Header as SidebarHeader, + Input as SidebarInput, + Inset as SidebarInset, + Menu as SidebarMenu, + MenuAction as SidebarMenuAction, + MenuBadge as SidebarMenuBadge, + MenuButton as SidebarMenuButton, + MenuItem as SidebarMenuItem, + MenuSkeleton as SidebarMenuSkeleton, + MenuSub as SidebarMenuSub, + MenuSubButton as SidebarMenuSubButton, + MenuSubItem as SidebarMenuSubItem, + Provider as SidebarProvider, + Rail as SidebarRail, + Separator as SidebarSeparator, + Trigger as SidebarTrigger, + Trigger, + useSidebar, +}; diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-content.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-content.svelte new file mode 100644 index 0000000..f121800 --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-content.svelte @@ -0,0 +1,24 @@ + + +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-footer.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-footer.svelte new file mode 100644 index 0000000..6259cb9 --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-footer.svelte @@ -0,0 +1,21 @@ + + +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-group-action.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-group-action.svelte new file mode 100644 index 0000000..fb84e4a --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-group-action.svelte @@ -0,0 +1,36 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-group-content.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-group-content.svelte new file mode 100644 index 0000000..415255f --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-group-content.svelte @@ -0,0 +1,21 @@ + + +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-group-label.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-group-label.svelte new file mode 100644 index 0000000..e292945 --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-group-label.svelte @@ -0,0 +1,34 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
    + {@render children?.()} +
    +{/if} diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-group.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-group.svelte new file mode 100644 index 0000000..ec18a69 --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-group.svelte @@ -0,0 +1,21 @@ + + +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-header.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-header.svelte new file mode 100644 index 0000000..a1b2db1 --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-header.svelte @@ -0,0 +1,21 @@ + + +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-input.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-input.svelte new file mode 100644 index 0000000..19b3666 --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-input.svelte @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-inset.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-inset.svelte new file mode 100644 index 0000000..5d9598f --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-inset.svelte @@ -0,0 +1,24 @@ + + +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-menu-action.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-menu-action.svelte new file mode 100644 index 0000000..fa3fb0c --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-menu-action.svelte @@ -0,0 +1,43 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte new file mode 100644 index 0000000..69e5a3c --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte @@ -0,0 +1,29 @@ + + +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-menu-button.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-menu-button.svelte new file mode 100644 index 0000000..c358b70 --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-menu-button.svelte @@ -0,0 +1,101 @@ + + + + +{#snippet Button({ props }: { props?: Record })} + {@const mergedProps = mergeProps(buttonProps, props)} + {#if child} + {@render child({ props: mergedProps })} + {:else} + + {/if} +{/snippet} + +{#if !tooltipContent} + {@render Button({})} +{:else} + + + {#snippet child({ props })} + {@render Button({ props })} + {/snippet} + + + +{/if} diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-menu-item.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-menu-item.svelte new file mode 100644 index 0000000..4db4453 --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-menu-item.svelte @@ -0,0 +1,21 @@ + + +
  • + {@render children?.()} +
  • diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte new file mode 100644 index 0000000..cc63b04 --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte @@ -0,0 +1,36 @@ + + +
    + {#if showIcon} + + {/if} + + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte new file mode 100644 index 0000000..987f104 --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte @@ -0,0 +1,43 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + + {@render children?.()} + +{/if} diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte new file mode 100644 index 0000000..681d0f1 --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte @@ -0,0 +1,21 @@ + + +
  • + {@render children?.()} +
  • diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte new file mode 100644 index 0000000..8ab1111 --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte @@ -0,0 +1,25 @@ + + +
      + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-menu.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-menu.svelte new file mode 100644 index 0000000..946ccce --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-menu.svelte @@ -0,0 +1,21 @@ + + +
      + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-provider.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-provider.svelte new file mode 100644 index 0000000..f9f8f9d --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-provider.svelte @@ -0,0 +1,53 @@ + + + + + +
    + {@render children?.()} +
    +
    diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-rail.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-rail.svelte new file mode 100644 index 0000000..c180cf5 --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-rail.svelte @@ -0,0 +1,36 @@ + + + diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-separator.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-separator.svelte new file mode 100644 index 0000000..5a7deda --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-separator.svelte @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-trigger.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-trigger.svelte new file mode 100644 index 0000000..c9a0b07 --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-trigger.svelte @@ -0,0 +1,35 @@ + + + diff --git a/frontend/src/lib/components/ui/sidebar/sidebar.svelte b/frontend/src/lib/components/ui/sidebar/sidebar.svelte new file mode 100644 index 0000000..ff6db69 --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar.svelte @@ -0,0 +1,104 @@ + + +{#if collapsible === "none"} +
    + {@render children?.()} +
    +{:else if sidebar.isMobile} + sidebar.openMobile, (v) => sidebar.setOpenMobile(v)} + {...restProps} + > + + + Sidebar + Displays the mobile sidebar. + +
    + {@render children?.()} +
    +
    +
    +{:else} + +{/if} diff --git a/frontend/src/lib/components/ui/skeleton/index.ts b/frontend/src/lib/components/ui/skeleton/index.ts new file mode 100644 index 0000000..186db21 --- /dev/null +++ b/frontend/src/lib/components/ui/skeleton/index.ts @@ -0,0 +1,7 @@ +import Root from "./skeleton.svelte"; + +export { + Root, + // + Root as Skeleton, +}; diff --git a/frontend/src/lib/components/ui/skeleton/skeleton.svelte b/frontend/src/lib/components/ui/skeleton/skeleton.svelte new file mode 100644 index 0000000..c7e3d26 --- /dev/null +++ b/frontend/src/lib/components/ui/skeleton/skeleton.svelte @@ -0,0 +1,17 @@ + + +
    diff --git a/frontend/src/lib/components/ui/switch/index.ts b/frontend/src/lib/components/ui/switch/index.ts new file mode 100644 index 0000000..f0e5fb7 --- /dev/null +++ b/frontend/src/lib/components/ui/switch/index.ts @@ -0,0 +1,7 @@ +import Root from "./switch.svelte"; + +export { + Root, + // + Root as Switch +}; diff --git a/frontend/src/lib/components/ui/switch/switch.svelte b/frontend/src/lib/components/ui/switch/switch.svelte new file mode 100644 index 0000000..5a51218 --- /dev/null +++ b/frontend/src/lib/components/ui/switch/switch.svelte @@ -0,0 +1,51 @@ + + + diff --git a/frontend/src/lib/components/ui/table/index.ts b/frontend/src/lib/components/ui/table/index.ts new file mode 100644 index 0000000..14695c8 --- /dev/null +++ b/frontend/src/lib/components/ui/table/index.ts @@ -0,0 +1,28 @@ +import Root from "./table.svelte"; +import Body from "./table-body.svelte"; +import Caption from "./table-caption.svelte"; +import Cell from "./table-cell.svelte"; +import Footer from "./table-footer.svelte"; +import Head from "./table-head.svelte"; +import Header from "./table-header.svelte"; +import Row from "./table-row.svelte"; + +export { + Root, + Body, + Caption, + Cell, + Footer, + Head, + Header, + Row, + // + Root as Table, + Body as TableBody, + Caption as TableCaption, + Cell as TableCell, + Footer as TableFooter, + Head as TableHead, + Header as TableHeader, + Row as TableRow, +}; diff --git a/frontend/src/lib/components/ui/table/table-body.svelte b/frontend/src/lib/components/ui/table/table-body.svelte new file mode 100644 index 0000000..29e9687 --- /dev/null +++ b/frontend/src/lib/components/ui/table/table-body.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/table/table-caption.svelte b/frontend/src/lib/components/ui/table/table-caption.svelte new file mode 100644 index 0000000..4696cff --- /dev/null +++ b/frontend/src/lib/components/ui/table/table-caption.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/table/table-cell.svelte b/frontend/src/lib/components/ui/table/table-cell.svelte new file mode 100644 index 0000000..1a2f033 --- /dev/null +++ b/frontend/src/lib/components/ui/table/table-cell.svelte @@ -0,0 +1,23 @@ + + + + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/table/table-footer.svelte b/frontend/src/lib/components/ui/table/table-footer.svelte new file mode 100644 index 0000000..b9b14eb --- /dev/null +++ b/frontend/src/lib/components/ui/table/table-footer.svelte @@ -0,0 +1,20 @@ + + +tr]:last:border-b-0", className)} + {...restProps} +> + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/table/table-head.svelte b/frontend/src/lib/components/ui/table/table-head.svelte new file mode 100644 index 0000000..c7c5e7e --- /dev/null +++ b/frontend/src/lib/components/ui/table/table-head.svelte @@ -0,0 +1,23 @@ + + + + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/table/table-header.svelte b/frontend/src/lib/components/ui/table/table-header.svelte new file mode 100644 index 0000000..f47d259 --- /dev/null +++ b/frontend/src/lib/components/ui/table/table-header.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/table/table-row.svelte b/frontend/src/lib/components/ui/table/table-row.svelte new file mode 100644 index 0000000..44346c4 --- /dev/null +++ b/frontend/src/lib/components/ui/table/table-row.svelte @@ -0,0 +1,26 @@ + + +svelte-css-wrapper]:[&>th,td]:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", + "focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", + className + )} +> + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/table/table.svelte b/frontend/src/lib/components/ui/table/table.svelte new file mode 100644 index 0000000..a334956 --- /dev/null +++ b/frontend/src/lib/components/ui/table/table.svelte @@ -0,0 +1,22 @@ + + +
    + + {@render children?.()} +
    +
    diff --git a/frontend/src/lib/components/ui/tabs/index.ts b/frontend/src/lib/components/ui/tabs/index.ts new file mode 100644 index 0000000..12d4327 --- /dev/null +++ b/frontend/src/lib/components/ui/tabs/index.ts @@ -0,0 +1,16 @@ +import Root from "./tabs.svelte"; +import Content from "./tabs-content.svelte"; +import List from "./tabs-list.svelte"; +import Trigger from "./tabs-trigger.svelte"; + +export { + Root, + Content, + List, + Trigger, + // + Root as Tabs, + Content as TabsContent, + List as TabsList, + Trigger as TabsTrigger, +}; diff --git a/frontend/src/lib/components/ui/tabs/tabs-content.svelte b/frontend/src/lib/components/ui/tabs/tabs-content.svelte new file mode 100644 index 0000000..340d65c --- /dev/null +++ b/frontend/src/lib/components/ui/tabs/tabs-content.svelte @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/lib/components/ui/tabs/tabs-list.svelte b/frontend/src/lib/components/ui/tabs/tabs-list.svelte new file mode 100644 index 0000000..08932b6 --- /dev/null +++ b/frontend/src/lib/components/ui/tabs/tabs-list.svelte @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/lib/components/ui/tabs/tabs-trigger.svelte b/frontend/src/lib/components/ui/tabs/tabs-trigger.svelte new file mode 100644 index 0000000..dced992 --- /dev/null +++ b/frontend/src/lib/components/ui/tabs/tabs-trigger.svelte @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/lib/components/ui/tabs/tabs.svelte b/frontend/src/lib/components/ui/tabs/tabs.svelte new file mode 100644 index 0000000..ef6cada --- /dev/null +++ b/frontend/src/lib/components/ui/tabs/tabs.svelte @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/lib/components/ui/textarea/index.ts b/frontend/src/lib/components/ui/textarea/index.ts new file mode 100644 index 0000000..ace797a --- /dev/null +++ b/frontend/src/lib/components/ui/textarea/index.ts @@ -0,0 +1,7 @@ +import Root from "./textarea.svelte"; + +export { + Root, + // + Root as Textarea, +}; diff --git a/frontend/src/lib/components/ui/textarea/textarea.svelte b/frontend/src/lib/components/ui/textarea/textarea.svelte new file mode 100644 index 0000000..7fcef1a --- /dev/null +++ b/frontend/src/lib/components/ui/textarea/textarea.svelte @@ -0,0 +1,23 @@ + + + diff --git a/frontend/src/lib/components/ui/tooltip/index.ts b/frontend/src/lib/components/ui/tooltip/index.ts new file mode 100644 index 0000000..aacf780 --- /dev/null +++ b/frontend/src/lib/components/ui/tooltip/index.ts @@ -0,0 +1,22 @@ +import { Tooltip as TooltipPrimitive } from "bits-ui"; +import Trigger from "./tooltip-trigger.svelte"; +import Content from "./tooltip-content.svelte"; + +// Handle SSR safely +const Root = TooltipPrimitive?.Root ?? (class {} as any); +const Provider = TooltipPrimitive?.Provider ?? (class {} as any); +const Portal = TooltipPrimitive?.Portal ?? (class {} as any); + +export { + Root, + Trigger, + Content, + Provider, + Portal, + // + Root as Tooltip, + Content as TooltipContent, + Trigger as TooltipTrigger, + Provider as TooltipProvider, + Portal as TooltipPortal, +}; diff --git a/frontend/src/lib/components/ui/tooltip/tooltip-content.svelte b/frontend/src/lib/components/ui/tooltip/tooltip-content.svelte new file mode 100644 index 0000000..b040bc9 --- /dev/null +++ b/frontend/src/lib/components/ui/tooltip/tooltip-content.svelte @@ -0,0 +1,47 @@ + + + + + {@render children?.()} + + {#snippet child({ props })} +
    + {/snippet} +
    +
    +
    diff --git a/frontend/src/lib/components/ui/tooltip/tooltip-trigger.svelte b/frontend/src/lib/components/ui/tooltip/tooltip-trigger.svelte new file mode 100644 index 0000000..1acdaa4 --- /dev/null +++ b/frontend/src/lib/components/ui/tooltip/tooltip-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/config/backend.ts b/frontend/src/lib/config/backend.ts new file mode 100644 index 0000000..2939a03 --- /dev/null +++ b/frontend/src/lib/config/backend.ts @@ -0,0 +1,51 @@ +/** + * Configuración de conexión al backend + * Detecta automáticamente el entorno y usa la URL correcta + */ + +export function getBackendUrl(): string { + // 1. Si existe variable de entorno, úsala (override manual) + if (import.meta.env.VITE_BACKEND_URL) { + return import.meta.env.VITE_BACKEND_URL; + } + + // 2. Detección automática basada en dónde corre el código + if (typeof window !== 'undefined') { + // CLIENTE (Browser): usar la URL pública del backend + // En local: http://localhost:8000 + // En prod: mismo dominio o dominio específico + const hostname = window.location.hostname; + + if (hostname === 'localhost' || hostname === '127.0.0.1') { + return 'http://localhost:8000/api'; + } + + // En producción, asumir que el backend está en el mismo dominio /api + // o usar un subdominio específico + return `${window.location.protocol}//${hostname}/api`; + } else { + // SERVIDOR (SvelteKit SSR/Endpoints): usar URL interna + // En Docker: http://backend:8000 + // En local: http://127.0.0.1:8000 (IPv4 explícito) + + // Detectar si estamos en Docker por hostname + const isDocker = process.env.HOSTNAME?.includes('docker'); + + if (isDocker) { + return 'http://backend:8000/api'; + } + + // En desarrollo local, usar IPv4 explícito para evitar problemas con IPv6 + return 'http://127.0.0.1:8000/api'; + } +} + +export const BACKEND_URL = getBackendUrl(); + +// Helper para logs +export function logBackendConfig() { + console.log('[Backend Config]', { + url: BACKEND_URL, + isServer: typeof window === 'undefined', + }); +} diff --git a/frontend/src/lib/config/shortcuts.ts b/frontend/src/lib/config/shortcuts.ts new file mode 100644 index 0000000..41efe0d --- /dev/null +++ b/frontend/src/lib/config/shortcuts.ts @@ -0,0 +1,27 @@ +/** + * Keyboard Shortcuts — plantilla base. + * Agrega aquí los atajos de navegación de tu proyecto. + * + * Estándares: + * Alt + Key → Navegación global (cambio de ruta) + * Ctrl + Key → Acciones locales (contexto específico) + */ + +export const GLOBAL_NAV = { + 'h': '/', // Home + 'n': 'SIDEBAR_FOCUS', // Foco en navegación lateral + 'j': 'MAIN_CONTENT_FOCUS', // Foco en contenido principal + // Agrega tus rutas aquí: 'u': '/dashboard/users' +} as const; + +export const STANDARD_ACTIONS = { + 's': 'SAVE', + 'n': 'NEW', + 'e': 'EXPORT', + 'd': 'DELETE', + 'f': 'FILTER', + 'Escape': 'CANCEL' +} as const; + +export type GlobalNavKey = keyof typeof GLOBAL_NAV; +export type ActionKey = keyof typeof STANDARD_ACTIONS; diff --git a/frontend/src/lib/csv-upload-row-count.ts b/frontend/src/lib/csv-upload-row-count.ts new file mode 100644 index 0000000..0fb1751 --- /dev/null +++ b/frontend/src/lib/csv-upload-row-count.ts @@ -0,0 +1,15 @@ +/** + * Cuenta filas de datos (excluye 1 línea de encabezado) en un CSV local. + * Asume primera línea no vacía = encabezado. + */ +export async function countCsvDataRows(file: File): Promise { + const text = await file.text(); + if (!text.trim()) return 0; + const lines = text.split(/\r\n|\r|\n/); + let nonEmpty = 0; + for (const line of lines) { + if (line.trim().length > 0) nonEmpty += 1; + } + if (nonEmpty <= 1) return 0; + return nonEmpty - 1; +} diff --git a/frontend/src/lib/date-utils.test.ts b/frontend/src/lib/date-utils.test.ts new file mode 100644 index 0000000..d1a8315 --- /dev/null +++ b/frontend/src/lib/date-utils.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from 'vitest' +import { + prepareDateForBackend, + loadServerDate, + addDaysLocal, + getCurrentLocalYear, + getCurrentLocalDate, + getCurrentLocalTime +} from './date-utils' + +const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/ +const ISO_DATETIME_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/ +const TIME_REGEX = /^\d{2}:\d{2}$/ + +describe('date-utils', () => { + + describe('prepareDateForBackend', () => { + + it('devuelve null si dateStr está vacío', () => { + expect(prepareDateForBackend('')).toBeNull() + }) + + it('devuelve ISO string con fecha y hora explícita', () => { + const result = prepareDateForBackend('2024-06-15', '09:30') + expect(result).toMatch(ISO_DATETIME_REGEX) + }) + + it('el resultado es una fecha JavaScript válida', () => { + const result = prepareDateForBackend('2024-06-15', '09:30') + expect(new Date(result!).toString()).not.toBe('Invalid Date') + }) + + it('usa 00:00 como hora por defecto', () => { + const result = prepareDateForBackend('2024-06-15') + expect(result).toMatch(ISO_DATETIME_REGEX) + }) + + }) + + describe('loadServerDate', () => { + + it.each([ + [null, ''], + [undefined, ''], + ['', ''], + ])('devuelve string vacío para %s', (input, expected) => { + expect(loadServerDate(input)).toBe(expected) + }) + + it('convierte ISO UTC a formato YYYY-MM-DD', () => { + const result = loadServerDate('2024-06-15T12:00:00.000Z') + expect(result).toMatch(ISO_DATE_REGEX) + }) + + it('retorna fecha plana sin modificarla', () => { + expect(loadServerDate('2024-06-15')).toBe('2024-06-15') + }) + + }) + + describe('addDaysLocal', () => { + + it('devuelve string vacío si dateStr está vacío', () => { + expect(addDaysLocal('', 5)).toBe('') + }) + + it.each([ + ['2024-01-01', 1, '2024-01-02'], + ['2024-01-31', 1, '2024-02-01'], + ['2024-03-01', -1, '2024-02-29'], + ])('suma %s + %s días = %s', (date, days, expected) => { + expect(addDaysLocal(date, days)).toBe(expected) + }) + + }) + + describe('getCurrentLocalYear', () => { + + it('devuelve 2 dígitos', () => { + expect(getCurrentLocalYear()).toMatch(/^\d{2}$/) + }) + + it('corresponde al año actual del sistema', () => { + const expected = String(new Date().getFullYear()).slice(-2) + expect(getCurrentLocalYear()).toBe(expected) + }) + + }) + + describe('getCurrentLocalDate', () => { + + it('devuelve formato YYYY-MM-DD', () => { + expect(getCurrentLocalDate()).toMatch(ISO_DATE_REGEX) + }) + + }) + + describe('getCurrentLocalTime', () => { + + it('devuelve formato HH:MM', () => { + expect(getCurrentLocalTime()).toMatch(TIME_REGEX) + }) + + }) + +}) \ No newline at end of file diff --git a/frontend/src/lib/date-utils.ts b/frontend/src/lib/date-utils.ts new file mode 100644 index 0000000..e21c9f2 --- /dev/null +++ b/frontend/src/lib/date-utils.ts @@ -0,0 +1,78 @@ +import { + getLocalTimeZone, + parseDate, + parseTime, + toCalendarDateTime, + fromDate, + now, + today +} from '@internationalized/date'; + +const localTimeZone = getLocalTimeZone(); + +/** + * Convierte fecha local (string input YYYY-MM-DD) + hora (HH:MM) a UTC ISO string. + * Útil para enviar datos al backend que espera timestamps absolutos. + */ +export function prepareDateForBackend(dateStr: string, timeStr: string = '00:00'): string | null { + if (!dateStr) return null; + try { + const date = parseDate(dateStr); + const time = parseTime(timeStr); + // Combinar fecha y hora usando la zona horaria local + const dateTime = toCalendarDateTime(date, time); + const zonedDateTime = dateTime.toDate(localTimeZone); + // Convertir a objeto Date nativo de JavaScript para obtener ISO string correcto + const jsDate = new Date(zonedDateTime.toString()); + return jsDate.toISOString(); + } catch (e) { + console.error('Error parsing date:', e); + return null; + } +} + +/** + * Convertir fechas que vienen del servidor (ISO UTC) a fecha local del calendario (YYYY-MM-DD string). + * Útil para poblar inputs type="date". + */ +export function loadServerDate(isoStr: string | null | undefined): string { + if (!isoStr) return ''; + try { + // Si incluye T es ISO completo, convertir a zona local + if (isoStr.includes('T')) { + const dateObj = new Date(isoStr); + const zonedDate = fromDate(dateObj, localTimeZone); + return `${zonedDate.year}-${String(zonedDate.month).padStart(2, '0')}-${String(zonedDate.day).padStart(2, '0')}`; + } + // Si no, asumir fecha plana YYYY-MM-DD + return isoStr.substring(0, 10); + } catch { + return isoStr || ''; + } +} + +/** + * Sumar días de forma segura con calendario y devolver string YYYY-MM-DD. + */ +export function addDaysLocal(dateStr: string, days: number): string { + if (!dateStr) return ''; + try { + return parseDate(dateStr).add({ days }).toString(); + } catch { + return ''; + } +} + +export function getCurrentLocalYear(): string { + const nowZoned = now(localTimeZone); + return String(nowZoned.year).slice(-2); +} + +export function getCurrentLocalDate(): string { + return today(localTimeZone).toString(); +} + +export function getCurrentLocalTime(): string { + const nowZoned = now(localTimeZone); + return `${String(nowZoned.hour).padStart(2, '0')}:${String(nowZoned.minute).padStart(2, '0')}`; +} diff --git a/frontend/src/lib/hooks/is-mobile.svelte.ts b/frontend/src/lib/hooks/is-mobile.svelte.ts new file mode 100644 index 0000000..4829c00 --- /dev/null +++ b/frontend/src/lib/hooks/is-mobile.svelte.ts @@ -0,0 +1,9 @@ +import { MediaQuery } from "svelte/reactivity"; + +const DEFAULT_MOBILE_BREAKPOINT = 768; + +export class IsMobile extends MediaQuery { + constructor(breakpoint: number = DEFAULT_MOBILE_BREAKPOINT) { + super(`max-width: ${breakpoint - 1}px`); + } +} diff --git a/frontend/src/lib/hooks/use-shortcuts.ts b/frontend/src/lib/hooks/use-shortcuts.ts new file mode 100644 index 0000000..d748a79 --- /dev/null +++ b/frontend/src/lib/hooks/use-shortcuts.ts @@ -0,0 +1,34 @@ +import { onMount, onDestroy } from 'svelte'; +import { shortcutStore, type ShortcutDef } from '$lib/stores/shortcut-store'; +import { page } from '$app/stores'; +import { get } from 'svelte/store'; +import { browser } from '$app/environment'; +import { hasAccessTokenInDocument } from '$lib/access-token-cookie-browser'; + +/** + * Hook to register shortcuts for a component lifecycle. + * Only works when user is authenticated (inside /dashboard routes). + * @param context Name of the context (e.g., 'Goods List') + * @param shortcuts Array of shortcut definitions + */ +export function useShortcuts(context: string, shortcuts: ShortcutDef[]) { + onMount(() => { + // Solo registrar atajos si estamos en una ruta autenticada + if (!browser) return; + + const currentPath = get(page)?.url?.pathname || ''; + const isAuthenticatedRoute = currentPath.startsWith('/dashboard'); + + // Verificar también que haya un token de acceso + const hasAccessToken = hasAccessTokenInDocument() || + localStorage.getItem('access_token'); + + if (isAuthenticatedRoute && hasAccessToken) { + shortcutStore.register(context, shortcuts); + } + }); + + onDestroy(() => { + shortcutStore.clear(context); + }); +} diff --git a/frontend/src/lib/i18n/csv-msg.ts b/frontend/src/lib/i18n/csv-msg.ts new file mode 100644 index 0000000..fbf56fa --- /dev/null +++ b/frontend/src/lib/i18n/csv-msg.ts @@ -0,0 +1,46 @@ +/** + * Textos CSV desde `csv-upload-messages.{en,es}.json` (rama `csv_upload` extraída de messages/). + * Mantener en sync al editar `messages/*.json` (p. ej. volver a exportar la clave csv_upload). + */ +import enPack from './csv-upload-messages.en.json'; +import esPack from './csv-upload-messages.es.json'; +import { baseLocale, getLocale } from '$lib/paraglide/runtime'; + +type CsvUploadPack = Record; + +function pickPack(): CsvUploadPack { + let raw: string; + try { + raw = String(getLocale()).toLowerCase(); + } catch { + raw = String(baseLocale).toLowerCase(); + } + const root = raw.startsWith('en') ? enPack : esPack; + return root as CsvUploadPack; +} + +function walk(root: Record, keys: string[]): unknown { + let cur: unknown = root; + for (const k of keys) { + if (cur === null || typeof cur !== 'object') return undefined; + cur = (cur as Record)[k]; + } + return cur; +} + +/** Navega `csv_upload.a.b.c` a partir de `subPath` = `a.b.c`. */ +export function csvMsg(subPath: string): string { + const pack = pickPack(); + if (!pack) return subPath; + const v = walk(pack as Record, subPath.split('.')); + return typeof v === 'string' ? v : subPath; +} + +/** Sustituye `{clave}` en el string del mensaje. */ +export function csvFmt(subPath: string, vars: Record = {}): string { + let s = csvMsg(subPath); + for (const [k, val] of Object.entries(vars)) { + s = s.replaceAll(`{${k}}`, String(val)); + } + return s; +} diff --git a/frontend/src/lib/i18n/csv-upload-messages.en.json b/frontend/src/lib/i18n/csv-upload-messages.en.json new file mode 100644 index 0000000..2ebba1a --- /dev/null +++ b/frontend/src/lib/i18n/csv-upload-messages.en.json @@ -0,0 +1,193 @@ +{ + "page_title": "CSV import", + "intro_help": "Left-click: upload CSV file. Right-click: download template.", + "tab_catalogos": "Catalogs", + "tab_transportes": "Transportation", + "tab_importacion": "Import", + "tab_exportacion": "Export", + "section_catalogs": "General Catalogs", + "section_transport": "Transportation", + "section_import": "Import operations", + "section_export": "Export operations", + "params_header": "Global parameters", + "config_prefix": "Settings", + "soon": "Coming soon", + "drop_here": "Drop the file!", + "groups": { + "permisos": "Permissions", + "impo_temp": "Temporary import", + "impo_def": "Definitive import", + "cmex": "Mexican purchases", + "expo_def": "Definitive export / regime change", + "expo_rep": "Export replenishment", + "manifest": "Manifest" + }, + "items": { + "customs_brokers": "Customs Brokers", + "clients_providers": "Clients and Providers", + "exchange_rates": "Exchange Rates", + "material_classes": "Classes", + "part_numbers": "Parts", + "boms": "BOMs", + "items": "Lines (permissions)", + "headers": "Headers (permissions)", + "historical_fractions": "Historical tariff fractions", + "pedimentos": "Pedimentos", + "transporters": "Carriers", + "transports": "Vehicles", + "drivers": "Drivers", + "trailers": "Trailers", + "imp_temp_header": "Header", + "imp_temp_details": "Lines", + "imp_temp_series": "Serial numbers", + "imp_def_header": "Header", + "imp_def_details": "Lines", + "imp_def_series": "Serial numbers", + "comp_mex_header": "Header", + "comp_mex_details": "Lines", + "comp_mex_series": "Serial numbers", + "exp_def_header": "Header", + "exp_def_details": "Lines", + "exp_def_series": "Serial numbers", + "exp_def_nodes": "NODES", + "exp_rep_header": "Header", + "exp_rep_details": "Lines", + "exp_rep_series": "Serial numbers", + "manifest_header": "Header" + }, + "params": { + "load_mode": "Load mode", + "date_format": "Date format", + "weight_unit": "Weight unit", + "autonumber_series": "Autonumber lines/series", + "load_subpartidas": "Load sub-lines", + "recalculate_pedimento_date": "Recalculate pedimento date", + "autonumber_remesas": "Autonumber consignments", + "recalculate_dates": "Recalculate dates", + "invoice_type": "Invoice type", + "is_regime_change": "Regime change" + }, + "options": { + "update": "Update", + "replace": "Replace", + "yes": "Yes", + "no": "No", + "kgs": "Kilograms (kg)", + "lbs": "Pounds (lb)", + "date_dd_mm": "DD/MM/YYYY", + "date_mm_dd": "MM/DD/YYYY", + "date_iso": "YYYY-MM-DD", + "afi": "AFIJO", + "normal": "NORMAL" + }, + "progress": { + "upload": "Uploading CSV file", + "scan": "Validating records on the server", + "commit": "Saving records to the database", + "upload_known": "Uploading file…", + "upload_unknown": "Uploading file (unknown size in browser)…", + "in_progress": "In progress…", + "resume_hint": "Resuming import saved in this tab…", + "rows_file": "File: ~{n} data row(s) — uploading (not yet validated on server)…", + "rows_scan": "Records processed: {current} / {total}", + "rows_commit": "Records saved: {current} / {total}", + "rows_commit_fallback": "Saving to database… ({current} / {total} using last known total)" + }, + "toast": { + "invalid_csv": "Invalid format. Only .csv files are allowed.", + "download_loading": "Downloading template…", + "download_ok": "Template downloaded.", + "download_err": "Could not download the template.", + "upload_err": "Could not upload the file.", + "upload_err_generic": "Unexpected error uploading the file.", + "scan_done": "Scan complete. Review the results.", + "import_done": "Import completed. Review the record list.", + "import_maybe_done": "Import may have completed. Review the record list.", + "stale_job": "This import is no longer available (session expired or job removed). You can start a new upload.", + "poll_err": "Could not fetch status", + "commit_err": "Could not start import", + "scan_alt": "Scan finished. If you do not see the modal, check the record list.", + "finished_none": "No records inserted. Review the errors below.", + "commit_warning_ok": "{inserted} inserted, {updated} updated. {skipped} rejected.", + "commit_warning_none": "No records inserted or updated. {skipped} rejected.", + "success_counts": "Import completed: {msg}", + "warn_skipped": "{n} records rejected or skipped", + "error_processing": "Processing error: {msg}", + "n_inserted": "{n} inserted", + "n_updated": "{n} updated", + "err_fetch_scan_result": "Could not fetch the scan result. Check the results modal.", + "err_unknown": "Unknown error", + "err_processing_fallback": "Processing error. Check the modal or details." + }, + "pending": { + "badge": "Pending", + "title": "Imports pending confirmation", + "description": "Scans ready to save to the database. Expired jobs disappear when you refresh.", + "refresh": "Refresh", + "empty": "No pending imports for this company.", + "checking": "Checking with the server…", + "total_rows": "Total rows", + "valid_rows": "Valid", + "resume": "Resume", + "remove": "Remove", + "profiles": { + "customs_brokers": "Customs Brokers", + "clients_providers": "Clients and Providers", + "exchange_rates": "Exchange Rates", + "pedimentos": "Pedimentos", + "material_classes": "Classes", + "vehicles": "Vehicles", + "drivers": "Drivers", + "trailers": "Trailers", + "transporters": "Carriers", + "part_numbers": "Parts", + "boms": "BOMs", + "exportacion": "Export operations", + "imports": "Import operations" + } + }, + "config_empty": "No module-specific settings.", + "modal": { + "title_pending": "Import validation", + "title_success": "Import successful", + "title_warning": "Import with remarks", + "desc_pending": "Review the preliminary analysis before confirming.", + "desc_done": "The import process has finished.", + "total_rows": "Total rows", + "valid_rows": "Valid", + "invalid_rows": "Invalid", + "errors": "Errors", + "errors_heading": "Scan errors (fix in your CSV)", + "errors_badge": "{shown} of {total} error(s)", + "errors_truncated": "Download the CSV to see all errors.", + "errors_missing_detail": "{count} row(s) had errors but details are not available. Ensure the server is up to date and upload again.", + "scan_ok_title": "File validated successfully", + "scan_ok_body": "All rows look correct and ready to import.", + "scan_problems_title": "Problems found in the file", + "scan_problems_body": "Fix the issues listed below in your CSV and upload again, or confirm to import only valid rows (invalid rows will be skipped).", + "inserted": "Inserted", + "updated": "Updated", + "rejected": "Rejected", + "rejected_hint": "See line-by-line detail in the table below.", + "ref_gaps_title": "Reference gaps (FK / catalogs)", + "ref_gaps_body": "There are {n} critical reference gap(s). Review catalogs and rejected rows before retrying.", + "ref_state_title": "Reference state", + "ref_state_ok": "References ready to operate (no critical gaps reported).", + "ref_state_other": "No numeric gaps; review the server message if applicable.", + "skipped_reasons_heading": "Rejection reasons summary", + "commit_errors_heading": "Error detail", + "rows_badge": "{n} rows", + "importing_records": "Importing records…", + "cancel_operation": "Cancel", + "processing": "Processing…", + "confirm_load": "Confirm import", + "close": "Close", + "th_line": "Line", + "th_column": "Column", + "th_message": "Message", + "th_solution": "Solution", + "th_reference": "Reference", + "th_reason": "Reason", + "download_csv": "Download CSV" + } +} diff --git a/frontend/src/lib/i18n/csv-upload-messages.es.json b/frontend/src/lib/i18n/csv-upload-messages.es.json new file mode 100644 index 0000000..326d284 --- /dev/null +++ b/frontend/src/lib/i18n/csv-upload-messages.es.json @@ -0,0 +1,193 @@ +{ + "page_title": "Importación CSV", + "intro_help": "Clic izquierdo: cargar archivo CSV. Clic derecho: descargar plantilla.", + "tab_catalogos": "Catálogos", + "tab_transportes": "Transportes", + "tab_importacion": "Importación", + "tab_exportacion": "Exportación", + "section_catalogs": "Catalogos Generales", + "section_transport": "Transportes", + "section_import": "Operaciones de importación", + "section_export": "Operaciones de exportación", + "params_header": "Parámetros globales", + "config_prefix": "Configuración", + "soon": "Próximamente", + "drop_here": "¡Suelta el archivo!", + "groups": { + "permisos": "Permisos", + "impo_temp": "Impo. temp.", + "impo_def": "Impo. def.", + "cmex": "Compras mex.", + "expo_def": "Expo. def./Cam. reg.", + "expo_rep": "Expo. rep.", + "manifest": "Manifiesto" + }, + "items": { + "customs_brokers": "Agentes Aduanales", + "clients_providers": "Clientes y Proveedores", + "exchange_rates": "Tipos de cambio", + "material_classes": "Clases", + "part_numbers": "Partes", + "boms": "BOMs", + "items": "Partidas (permisos)", + "headers": "Encabezados (permisos)", + "historical_fractions": "Fracciones históricas", + "pedimentos": "Pedimentos", + "transporters": "Transportistas", + "transports": "Vehículos", + "drivers": "Conductores", + "trailers": "Trailers", + "imp_temp_header": "Encabezado", + "imp_temp_details": "Partidas", + "imp_temp_series": "Series", + "imp_def_header": "Encabezado", + "imp_def_details": "Partidas", + "imp_def_series": "Series", + "comp_mex_header": "Encabezado", + "comp_mex_details": "Partidas", + "comp_mex_series": "Series", + "exp_def_header": "Encabezado", + "exp_def_details": "Partidas", + "exp_def_series": "Series", + "exp_def_nodes": "NODES", + "exp_rep_header": "Encabezado", + "exp_rep_details": "Partidas", + "exp_rep_series": "Series", + "manifest_header": "Encabezado" + }, + "params": { + "load_mode": "Modo de carga", + "date_format": "Formato de fecha", + "weight_unit": "Unidad de peso", + "autonumber_series": "Autonumerar partidas/series", + "load_subpartidas": "Levantar subpartidas", + "recalculate_pedimento_date": "Recalcular fecha pedimento", + "autonumber_remesas": "Autonumerar remesas", + "recalculate_dates": "Recalcular fechas", + "invoice_type": "Tipo de factura", + "is_regime_change": "Es cambio de régimen" + }, + "options": { + "update": "Actualizar", + "replace": "Reemplazar", + "yes": "Sí", + "no": "No", + "kgs": "Kilos (kg)", + "lbs": "Libras (lb)", + "date_dd_mm": "DD/MM/YYYY", + "date_mm_dd": "MM/DD/YYYY", + "date_iso": "YYYY-MM-DD", + "afi": "AFIJO", + "normal": "NORMAL" + }, + "progress": { + "upload": "Subiendo archivo CSV", + "scan": "Validando registros en el servidor", + "commit": "Grabando registros en base de datos", + "upload_known": "Subiendo archivo…", + "upload_unknown": "Subiendo archivo (tamaño desconocido en el navegador)…", + "in_progress": "En proceso…", + "resume_hint": "Reanudando la importación guardada en esta pestaña…", + "rows_file": "Archivo: ~{n} fila(s) de datos — subiendo (aún no se validan registros en servidor)…", + "rows_scan": "Registros procesados: {current} / {total}", + "rows_commit": "Registros grabados: {current} / {total}", + "rows_commit_fallback": "Grabando en base de datos… ({current} / {total} según último total conocido)" + }, + "toast": { + "invalid_csv": "Formato inválido. Solo se permiten archivos .csv", + "download_loading": "Descargando plantilla…", + "download_ok": "Plantilla descargada.", + "download_err": "Error al descargar la plantilla", + "upload_err": "Error al subir el archivo", + "upload_err_generic": "Error inesperado al subir el archivo", + "scan_done": "Escaneo completado. Revisa los resultados.", + "import_done": "Importación completada. Revisa el listado de registros.", + "import_maybe_done": "La importación pudo completarse. Revisa el listado de registros.", + "stale_job": "Esta importación ya no está disponible (sesión expirada o trabajo eliminado). Puedes iniciar una nueva carga.", + "poll_err": "Error al consultar el estado", + "commit_err": "Error al iniciar la importación", + "scan_alt": "El escaneo terminó. Si no ves el modal, revisa el listado de registros.", + "finished_none": "No se insertaron registros. Revisa los errores a continuación.", + "commit_warning_ok": "{inserted} insertados, {updated} actualizados. {skipped} rechazados.", + "commit_warning_none": "No se insertaron ni actualizaron registros. {skipped} rechazados.", + "success_counts": "Importación completada: {msg}", + "warn_skipped": "{n} registros fueron rechazados u omitidos", + "error_processing": "Error en el procesamiento: {msg}", + "n_inserted": "{n} insertados", + "n_updated": "{n} actualizados", + "err_fetch_scan_result": "Error al obtener el resultado. Revisa el modal de resultados.", + "err_unknown": "Error desconocido", + "err_processing_fallback": "Error en el procesamiento. Revisa el modal o los detalles." + }, + "pending": { + "badge": "Pendientes", + "title": "Importaciones pendientes de confirmar", + "description": "Escaneos listos para insertar en base de datos. Si el trabajo ya expiró en el servidor, desaparecerán al actualizar.", + "refresh": "Actualizar", + "empty": "No hay importaciones pendientes para esta empresa.", + "checking": "Comprobando con el servidor…", + "total_rows": "Total filas", + "valid_rows": "Válidas", + "resume": "Reanudar", + "remove": "Quitar", + "profiles": { + "customs_brokers": "Agentes Aduanales", + "clients_providers": "Clientes y Proveedores", + "exchange_rates": "Tipos de cambio", + "pedimentos": "Pedimentos", + "material_classes": "Clases", + "vehicles": "Vehículos", + "drivers": "Conductores", + "trailers": "Trailers", + "transporters": "Transportistas", + "part_numbers": "Partes", + "boms": "BOMs", + "exportacion": "Exportación (operaciones)", + "imports": "Importación (operaciones)" + } + }, + "config_empty": "No hay configuraciones específicas para este módulo.", + "modal": { + "title_pending": "Validación de importación", + "title_success": "Importación exitosa", + "title_warning": "Importación con observaciones", + "desc_pending": "Revise el análisis preliminar antes de confirmar la carga de datos.", + "desc_done": "El proceso de importación ha finalizado.", + "total_rows": "Total filas", + "valid_rows": "Válidos", + "invalid_rows": "Inválidos", + "errors": "Errores", + "errors_heading": "Detalle de errores (para corregir en el CSV)", + "errors_badge": "{shown} de {total} error(es)", + "errors_truncated": "Para consultar el resto de errores, descargue el CSV.", + "errors_missing_detail": "Se detectaron {count} fila(s) con errores pero el detalle no está disponible. Asegúrese de que el servidor esté actualizado y vuelva a subir el archivo.", + "scan_ok_title": "Archivo validado correctamente", + "scan_ok_body": "Todos los registros parecen correctos y listos para importar.", + "scan_problems_title": "Se detectaron problemas en el archivo", + "scan_problems_body": "Corrija los datos indicados abajo en su CSV y vuelva a subir, o confirme para importar solo las filas válidas (las erróneas se omitirán).", + "inserted": "Insertados", + "updated": "Actualizados", + "rejected": "Rechazados", + "rejected_hint": "Revisa el detalle por línea en la tabla inferior.", + "ref_gaps_title": "Brechas de referencia (FK / catálogos)", + "ref_gaps_body": "Hay {n} brecha(s) crítica(s) de referencia. Revisa catálogos y el detalle de filas rechazadas antes de reintentar.", + "ref_state_title": "Estado de referencias", + "ref_state_ok": "Referencias listas para operar (sin brechas críticas reportadas).", + "ref_state_other": "Sin brechas numéricas; revisa el mensaje del servidor si aplica.", + "skipped_reasons_heading": "Resumen de motivos de rechazo", + "commit_errors_heading": "Detalle de errores", + "rows_badge": "{n} filas", + "importing_records": "Importando registros…", + "cancel_operation": "Cancelar", + "processing": "Procesando…", + "confirm_load": "Confirmar carga", + "close": "Cerrar", + "th_line": "Línea", + "th_column": "Columna", + "th_message": "Mensaje", + "th_solution": "Solución", + "th_reference": "Referencia", + "th_reason": "Motivo", + "download_csv": "Descargar CSV" + } +} diff --git a/frontend/src/lib/i18n/doda-form/messages.en.json b/frontend/src/lib/i18n/doda-form/messages.en.json new file mode 100644 index 0000000..cff1084 --- /dev/null +++ b/frontend/src/lib/i18n/doda-form/messages.en.json @@ -0,0 +1,195 @@ +{ + "shortcuts_scope": "DODA form", + "title_new": "New DODA", + "title_edit": "Edit DODA", + "description_catalog": "Catalogs · DODA", + "tab_general": "General", + "tab_seals_sat": "Seals and SAT", + "shortcuts_hint": "Alt+1/2 · Ctrl+S save · Esc cancel", + "btn_cancel": "Cancel", + "btn_save": "Save", + "btn_saving": "Saving...", + "btn_save_changes": "Save changes", + "btn_create_doda": "Create DODA", + "btn_accept": "OK", + "card_broker_customs": "Customs agent and office", + "card_transport": "Transport", + "card_control": "Control and dispatch", + "card_sat_chain": "Original chain and signatures (SAT)", + "label_responsible": "Broker", + "label_patent": "Patent", + "label_dispatch": "Dispatch office", + "label_section_es": "E/S section", + "label_operation_type": "Operation type", + "label_transporter": "Carrier", + "label_transport_id": "Transport ID", + "label_caat": "CAAT", + "label_doda_date": "DODA date", + "label_status": "Status", + "label_dispatch_type": "Dispatch type", + "label_unique_badge": "Unique badge", + "label_integration_num": "Integration No.", + "label_transaction_num": "Transaction No.", + "label_fast_id": "Fast ID", + "label_last_user": "Last user", + "label_original_chain": "Original chain", + "label_serial_cert": "Serial (certificate)", + "label_uuid_cp": "Carta porte UUID", + "label_electronic_sig": "Electronic signature", + "label_sat_cert": "SAT certificate", + "label_sat_chain": "Original SAT chain", + "ph_aga": "AGA key", + "ph_0000": "0000", + "ph_000": "000", + "ph_select": "Select", + "ph_plate": "Plate / vehicle ID", + "ph_dash": "—", + "ph_yyyymmdd": "YYYYMMDD", + "ph_badge_pita": "N/A — PITA", + "ph_badge_num": "Badge no.", + "ph_example_container": "E.g. 53056", + "op_import": "I — Import", + "op_export": "E — Export", + "type_pita": "PITA", + "type_doda": "DODA", + "vu_checking": "Verifying agent VU DODA…", + "vu_incomplete": "VU DODA incomplete: agent needs .cer, .key, and DODA FIEL password.", + "vu_complete": "VU DODA complete for API submission.", + "badge_required_hint": "Required for DODA filing API.", + "pedimentos": "Pedimentos", + "lines": "lines", + "containers": "Containers", + "american_pedimentos": "U.S. pedimentos", + "seals_block_title": "Seals — total in DODA: {n} / 8", + "seals_help": "Select a container. Maximum 8 seals per DODA (SCAII).", + "seals_select_container": "Select a container in the table to view or edit its seals.", + "container_no_id_warning": "Container not saved on server. Enter value, press Save; new containers are sent and reloaded with id for seals.", + "container_line_info": "Container:", + "seal_on_line": "seal(s) on this line", + "line_word": "Line", + "btn_add_seal": "Add seal", + "btn_seal_delete": "Delete", + "seals_empty_line": "No seals on this container.", + "col_line": "Line", + "col_auth_patent": "Auth. patent", + "col_document": "Document", + "col_remesa": "Shipment", + "col_cove": "COVE", + "col_umc": "UMC", + "col_cash_usd": "Cash USD", + "col_diff_usd": "Difference USD", + "col_dta_niu": "DTA NIU", + "col_art7": "Art. 7", + "col_container": "Container", + "col_seals": "Seals", + "col_seal_value": "Seal", + "col_american_type": "Type", + "col_american_ped": "U.S. pedimento", + "col_pedimento_only": "U.S. pedimento", + "yes": "Yes", + "no": "No", + "child_empty": "No rows. “New” to add.", + "child_new": "New", + "child_edit": "Edit", + "child_delete": "Delete", + "modal_container_new": "New container", + "modal_container_edit": "Edit container", + "modal_container_desc": "Enter the container value for the DODA declaration.", + "label_container_value": "Container value", + "modal_seals_in_container": "Seals in container", + "seal_modal_title": "Containers > Seal", + "seal_modal_desc": "Enter the seal value for the selected container.", + "label_seal": "Seal", + "ph_seal": "Seal value", + "american_modal_title": "U.S. pedimento", + "american_modal_desc": "Enter type and value of the U.S. pedimento.", + "label_american_type_short": "U.S. type", + "label_american_value": "U.S. pedimento", + "ph_american_value": "U.S. pedimento value", + "line_label": "Line:", + "select_type": "Select type", + "american_cat_6": "AMERICAN PEDIMENTO", + "american_cat_7": "SELF-DECLARATION", + "american_cat_8": "NOT PRESENT", + "err_american_tipo_required": "U.S. pedimento type is required.", + "err_american_tipo_import": "U.S. pedimento type is not valid for import (must be 1, 2, 3, 4, or 5).", + "err_american_tipo_export": "U.S. pedimento type is not valid for export (must be 6, 7, or 8).", + "err_american_op_undefined": "Set operation type (I/E) before validating the U.S. pedimento.", + "err_company": "Select a company", + "err_responsible": "Broker is required", + "err_patent": "Patent is required", + "err_transport": "Transport ID is required. Select a vehicle.", + "err_badge": "Unique badge number is required for DODA filing.", + "err_vu_wait": "Wait for agent VU DODA check to finish, then try again.", + "err_vu_config": "The customs agent does not have full VU DODA config (.cer, .key, DODA FIEL password).", + "err_min_containers": "Add at least one container for API submission.", + "err_american_new_lines": "Enter the U.S. pedimento value for each new line.", + "err_save": "Error saving", + "toast_saved": "Changes saved successfully.", + "toast_created": "DODA created successfully.", + "load_error": "Could not load DODA", + "warn_vu_incomplete": "This DODA’s agent does not have full VU DODA (.cer, .key, DODA FIEL password).", + "warn_vu_fetch": "Could not validate the agent’s VU settings.", + "warn_broker_select": "Selected agent has incomplete VU DODA. Configure in Customs agents before generating.", + "seal_save_first": "Save the DODA before managing seals.", + "seal_pick_container": "Select a container in the table.", + "seal_not_persisted": "This container is not on the server yet. Save the DODA and reload.", + "seal_empty": "Seal cannot be empty.", + "seal_max": "DODA already has the maximum 8 seals.", + "seal_add_err": "Error adding seal", + "seal_delete_err": "Error removing seal", + "pedimento_remove_blocked": "Cannot remove pedimentos already saved on the server here.", + "container_delete_err": "Error deleting container", + "american_delete_err": "Error deleting U.S. pedimento", + "container_update_err": "Error updating container", + "american_cannot_edit_persisted": "To change saved U.S. pedimentos, remove and add again.", + "err_american_value": "Enter the U.S. pedimento value.", + "err_american_type_or_value": "Enter type and/or U.S. pedimento value.", + "err_containers_max": "A DODA can have at most 4 containers.", + "err_container_empty": "Container value cannot be empty.", + "err_container_not_found": "Container to edit not found.", + "pedimento_selector_title": "Containers > Seal", + "list_page_subtitle": "Manage your Customs Operation Documents (DODA)", + "list_btn_new": "New DODA", + "list_card_title": "DODA list", + "list_ph_folio": "Folio", + "list_ph_patent": "Patent", + "list_filter_status_ph": "Status", + "list_filter_status_all": "All", + "list_filter_op_import": "Import", + "list_filter_op_export": "Export", + "list_filter_op": "Operation", + "list_filter_op_all": "All", + "list_btn_clear": "Clear", + "list_showing": "Showing {a} of {b} records", + "list_active_filters": "Active filters: {n}", + "list_btn_edit": "Edit", + "list_btn_print": "Print", + "list_toast_reload_error": "Error reloading data", + "list_elig_error_prefix": "Error checking eligibility: ", + "list_elig_not_meet": "This DODA does not meet the filing requirements.", + "list_alta_error_prefix": "Error sending DODA filing: ", + "list_print_error": "Error generating DODA PDF", + "list_alta_complete": "DODA filing completed successfully", + "list_shortcuts_scope": "DODA list", + "list_col_folio": "Folio", + "list_col_doda_date": "DODA date", + "list_col_desp": "Cstm.", + "list_col_patent": "Patent", + "list_col_pedimentos": "Pedimento(s)", + "list_col_remesas": "Shipment(s)", + "list_col_integracion": "Integration", + "list_col_trans": "Trans. no.", + "list_col_id_transport": "Transport ID", + "list_col_caat": "CAAT", + "list_col_user": "User", + "list_col_status": "Status", + "list_loading_more": "Loading more...", + "list_scroll_for_more": "Scroll to load more", + "list_confirm_delete": "Are you sure you want to delete this DODA record?", + "list_toast_delete_ok": "DODA deleted successfully", + "list_toast_delete_err": "Error deleting DODA", + "list_filter_i": "I — Import", + "list_filter_e": "E — Export", + "list_no_results": "No results." +} diff --git a/frontend/src/lib/i18n/doda-form/messages.es.json b/frontend/src/lib/i18n/doda-form/messages.es.json new file mode 100644 index 0000000..b0f8347 --- /dev/null +++ b/frontend/src/lib/i18n/doda-form/messages.es.json @@ -0,0 +1,195 @@ +{ + "shortcuts_scope": "Formulario DODA", + "title_new": "Nuevo DODA", + "title_edit": "Editar DODA", + "description_catalog": "Catálogos · DODA", + "tab_general": "General", + "tab_seals_sat": "Sellos y SAT", + "shortcuts_hint": "Alt+1/2 · Ctrl+S guardar · Esc cancelar", + "btn_cancel": "Cancelar", + "btn_save": "Guardar", + "btn_saving": "Guardando...", + "btn_save_changes": "Guardar cambios", + "btn_create_doda": "Crear DODA", + "btn_accept": "Aceptar", + "card_broker_customs": "Agente aduanal y aduana", + "card_transport": "Transporte", + "card_control": "Control y despacho", + "card_sat_chain": "Cadena original y firmas (SAT)", + "label_responsible": "Responsable", + "label_patent": "Patente", + "label_dispatch": "Aduana despacho", + "label_section_es": "Aduana sección E/S", + "label_operation_type": "Tipo operación", + "label_transporter": "Transportista", + "label_transport_id": "ID transporte", + "label_caat": "CAAT", + "label_doda_date": "Fecha DODA", + "label_status": "Estatus", + "label_dispatch_type": "Tipo despacho", + "label_unique_badge": "Gafete único", + "label_integration_num": "Núm. integración", + "label_transaction_num": "Núm. transacción", + "label_fast_id": "Fast ID", + "label_last_user": "Último usuario", + "label_original_chain": "Cadena original", + "label_serial_cert": "Núm. serie (certificado)", + "label_uuid_cp": "UUID carta porte", + "label_electronic_sig": "Firma electrónica", + "label_sat_cert": "Certificado SAT", + "label_sat_chain": "Cadena original SAT", + "ph_aga": "Clave AGA", + "ph_0000": "0000", + "ph_000": "000", + "ph_select": "Seleccionar", + "ph_plate": "Placa / ID vehículo", + "ph_dash": "—", + "ph_yyyymmdd": "AAAAMMDD", + "ph_badge_pita": "N/A — PITA", + "ph_badge_num": "Núm. gafete", + "ph_example_container": "Ej. 53056", + "op_import": "I — Importación", + "op_export": "E — Exportación", + "type_pita": "PITA", + "type_doda": "DODA", + "vu_checking": "Verificando VU DODA del agente…", + "vu_incomplete": "VU DODA incompleta: se requiere .cer, .key y clave FIEL DODA del agente.", + "vu_complete": "VU DODA completa para envío a API.", + "badge_required_hint": "Requerido para alta DODA en API.", + "pedimentos": "Pedimentos", + "lines": "líneas", + "containers": "Contenedores", + "american_pedimentos": "Pedimentos americanos", + "seals_block_title": "Precintos (candados) — total en el DODA: {n} / 8", + "seals_help": "Selecciona un contenedor en la tabla. Máximo 8 precintos en todo el DODA (regla SCAII).", + "seals_select_container": "Selecciona un contenedor en la tabla de contenedores para ver o editar sus precintos.", + "container_no_id_warning": "Contenedor sin id en el servidor. Completa el valor, pulsa Guardar (arriba); al guardar se envían contenedores nuevos y se recargan con id para precintos.", + "container_line_info": "Contenedor:", + "seal_on_line": "precinto(s) en esta línea", + "line_word": "Línea", + "btn_add_seal": "Agregar precinto", + "btn_seal_delete": "Eliminar", + "seals_empty_line": "Sin precintos en este contenedor.", + "col_line": "Línea", + "col_auth_patent": "Patente auth.", + "col_document": "Documento", + "col_remesa": "Remesa", + "col_cove": "COVE", + "col_umc": "UMC", + "col_cash_usd": "Efectivo USD", + "col_diff_usd": "Diferencia USD", + "col_dta_niu": "DTA NIU", + "col_art7": "Art. 7", + "col_container": "Contenedor", + "col_seals": "Precintos", + "col_seal_value": "Precinto", + "col_american_type": "Tipo", + "col_american_ped": "Pedimento americano", + "col_pedimento_only": "Pedimento americano", + "yes": "Sí", + "no": "No", + "child_empty": "Sin filas. «Nuevo» para añadir.", + "child_new": "Nuevo", + "child_edit": "Editar", + "child_delete": "Borrar", + "modal_container_new": "Nuevo contenedor", + "modal_container_edit": "Editar contenedor", + "modal_container_desc": "Captura el valor del contenedor para la declaración DODA.", + "label_container_value": "Valor contenedor", + "modal_seals_in_container": "Precintos del contenedor", + "seal_modal_title": "Contenedores > Precinto", + "seal_modal_desc": "Captura el valor del precinto para el contenedor seleccionado.", + "label_seal": "Precinto", + "ph_seal": "Valor del precinto", + "american_modal_title": "Pedimento Americano", + "american_modal_desc": "Captura el tipo y valor del pedimento americano.", + "label_american_type_short": "Tipo Ped. Americano", + "label_american_value": "Pedimento Americano", + "ph_american_value": "Valor pedimento americano", + "line_label": "Línea:", + "select_type": "Selecciona tipo", + "american_cat_6": "PEDIMENTO AMERICANO", + "american_cat_7": "AUTODECLARACION", + "american_cat_8": "NO PRESENTA", + "err_american_tipo_required": "El tipo de pedimento americano es obligatorio.", + "err_american_tipo_import": "El tipo de pedimento americano no es correcto para importación (debe ser 1, 2, 3, 4 o 5).", + "err_american_tipo_export": "El tipo de pedimento americano no es correcto para exportación (debe ser 6, 7 u 8).", + "err_american_op_undefined": "Define el tipo de operación (I/E) antes de validar el pedimento americano.", + "err_company": "Selecciona una compañía", + "err_responsible": "El Responsable es requerido", + "err_patent": "El Agente Aduanal (Patente) es requerido", + "err_transport": "La Identificación de Transporte es requerida. Selecciona un vehículo.", + "err_badge": "El Número de Gafete Único es requerido para Alta DODA.", + "err_vu_wait": "Espera a que termine la verificación VU DODA del agente e intenta de nuevo.", + "err_vu_config": "El agente aduanal no tiene configuración VU DODA completa (.cer, .key y clave FIEL DODA).", + "err_min_containers": "Agrega al menos un contenedor con valor para el envío a API.", + "err_american_new_lines": "Indique el valor del pedimento americano en cada línea nueva.", + "err_save": "Error al guardar", + "toast_saved": "Cambios guardados correctamente.", + "toast_created": "DODA creado correctamente.", + "load_error": "No se pudo cargar la información del DODA", + "warn_vu_incomplete": "El agente aduanal de este DODA no tiene VU DODA completa (.cer, .key y clave FIEL DODA).", + "warn_vu_fetch": "No se pudo validar la configuración VU del agente aduanal.", + "warn_broker_select": "El agente seleccionado no tiene VU DODA completa (.cer, .key y clave FIEL DODA). Configúralo en Agentes Aduanales antes de generar.", + "seal_save_first": "Guarda el DODA antes de gestionar precintos.", + "seal_pick_container": "Selecciona un contenedor en la tabla.", + "seal_not_persisted": "Este contenedor aún no está guardado en el servidor. Guarda el DODA (Guardar) y vuelve a abrir o recarga.", + "seal_empty": "El precinto no puede estar vacío.", + "seal_max": "El DODA ya tiene el máximo de 8 precintos.", + "seal_add_err": "Error al agregar el precinto", + "seal_delete_err": "Error al eliminar el precinto", + "pedimento_remove_blocked": "Los pedimentos guardados en servidor no se pueden quitar aquí.", + "container_delete_err": "Error al eliminar el contenedor", + "american_delete_err": "Error al eliminar el pedimento americano", + "container_update_err": "Error al actualizar el contenedor", + "american_cannot_edit_persisted": "Para editar pedimentos americanos guardados, elimínalo y créalo nuevamente.", + "err_american_value": "Indique el valor del pedimento americano.", + "err_american_type_or_value": "Capture tipo o valor del pedimento americano.", + "err_containers_max": "El DODA solo puede tener máximo 4 contenedores.", + "err_container_empty": "El valor del contenedor no puede estar vacío.", + "err_container_not_found": "No se encontró el contenedor a editar.", + "pedimento_selector_title": "Contenedores > Precinto", + "list_page_subtitle": "Gestiona tus Documentos de Operación Aduanera (DODA)", + "list_btn_new": "Nuevo DODA", + "list_card_title": "Listado de DODA", + "list_ph_folio": "Folio", + "list_ph_patent": "Patente", + "list_filter_status_ph": "Estatus", + "list_filter_status_all": "Todos", + "list_filter_op_import": "Importación", + "list_filter_op_export": "Exportación", + "list_filter_op": "Operación", + "list_filter_op_all": "Todas", + "list_btn_clear": "Limpiar", + "list_showing": "Mostrando {a} de {b} registros", + "list_active_filters": "Filtros activos: {n}", + "list_btn_edit": "Editar", + "list_btn_print": "Imprimir", + "list_toast_reload_error": "Error al recargar datos", + "list_elig_error_prefix": "Error al verificar elegibilidad: ", + "list_elig_not_meet": "El DODA no cumple con los requisitos de alta.", + "list_alta_error_prefix": "Error al enviar alta DODA: ", + "list_print_error": "Error al generar el PDF del DODA", + "list_alta_complete": "Alta DODA completada correctamente", + "list_shortcuts_scope": "Lista DODA", + "list_col_folio": "Folio", + "list_col_doda_date": "Fecha DODA", + "list_col_desp": "Desp.", + "list_col_patent": "Patente", + "list_col_pedimentos": "Pedimento(s)", + "list_col_remesas": "Remesa(s)", + "list_col_integracion": "Integración", + "list_col_trans": "Núm. Transacción", + "list_col_id_transport": "Id. Transporte", + "list_col_caat": "CAAT", + "list_col_user": "Usuario", + "list_col_status": "Estatus", + "list_loading_more": "Cargando más...", + "list_scroll_for_more": "Desplázate para cargar más", + "list_confirm_delete": "¿Está seguro de eliminar este registro DODA?", + "list_toast_delete_ok": "DODA eliminado correctamente", + "list_toast_delete_err": "Error al eliminar DODA", + "list_filter_i": "I - Importación", + "list_filter_e": "E - Exportación", + "list_no_results": "No hay resultados." +} diff --git a/frontend/src/lib/i18n/messages.ts b/frontend/src/lib/i18n/messages.ts new file mode 100644 index 0000000..ffc3ed0 --- /dev/null +++ b/frontend/src/lib/i18n/messages.ts @@ -0,0 +1,39 @@ +import * as generatedMessages from '$lib/paraglide/messages'; + +const normalizedMessageKeyCache = new Map(); +const generatedMessagesAny: Record = generatedMessages; + +export const m = new Proxy({} as typeof generatedMessages, { + get(_target, property: string | symbol) { + if (typeof property !== 'string') { + return undefined; + } + + const directValue = generatedMessagesAny[property]; + if (directValue !== undefined) { + return directValue; + } + + const cachedKey = normalizedMessageKeyCache.get(property); + if (cachedKey && generatedMessagesAny[cachedKey] !== undefined) { + return generatedMessagesAny[cachedKey]; + } + + const dottedProperty = property.replace(/_/g, '.'); + if (generatedMessagesAny[dottedProperty] !== undefined) { + normalizedMessageKeyCache.set(property, dottedProperty); + return generatedMessagesAny[dottedProperty]; + } + + const normalizedProperty = property.replace(/\./g, '_'); + const matchingKey = Object.keys(generatedMessagesAny).find( + (key) => key.replace(/\./g, '_') === normalizedProperty + ); + if (matchingKey) { + normalizedMessageKeyCache.set(property, matchingKey); + return generatedMessagesAny[matchingKey]; + } + + return generatedMessagesAny[property]; + } +}) as typeof generatedMessages; diff --git a/frontend/src/lib/i18n/trailer-types.ts b/frontend/src/lib/i18n/trailer-types.ts new file mode 100644 index 0000000..28889cd --- /dev/null +++ b/frontend/src/lib/i18n/trailer-types.ts @@ -0,0 +1,65 @@ +import { getLocale } from '$lib/paraglide/runtime'; + +/** + * Mapeo de traducciones para el catálogo de Tipos de Trailer (GTipoTrailer). + * Se usa la clave (trailer_type_key) para obtener la descripción en español. + */ +const TRAILER_TYPE_ES: Record = { + '20': 'Contenedor marítimo de 20 pies - Techo abierto', + '2B': 'Contenedor marítimo de 20 pies - Techo cerrado', + '40': 'Contenedor marítimo de 40 pies - Techo abierto', + '4B': 'Contenedor marítimo de 40 pies - Techo cerrado', + 'BI': 'Remolque para bebidas', + 'CB': 'Remolque de cuello de ganso', + 'CH': 'Chasis', + 'CL': 'Contenedor marítimo de otra longitud - Techo cerrado', + 'CU': 'Contenedor marítimo de otra longitud - Techo abierto', + 'CZ': 'Contenedor refrigerado', + 'DD': 'Remolque de doble caída', + 'DT': 'Remolque de caída trasera', + 'FR': 'Remolque flat rack', + 'FT': 'Plataforma / Cama plana', + 'HC': 'Remolque tolva (cubierto)', + 'HE': 'Remolque para caballos', + 'HO': 'Remolque tolva (abierto)', + 'HP': 'Remolque tolva (descarga neumática cubierto)', + 'L1': 'Pipa / Tanque (líquidos) no caldeado / no aislado', + 'L2': 'Pipa / Tanque (líquidos) caldeado / no aislado', + 'L3': 'Pipa / Tanque (líquidos) no caldeado / aislado', + 'L4': 'Pipa / Tanque (líquidos) caldeado / aislado', + 'LP': 'Remolque para troncos / tubería / postes', + 'LT': 'Remolque para ganado', + 'NC': 'Sin equipo', + 'OE': 'Otro', + 'RD': 'Remolque de rack fijo / doble caída', + 'RG': 'Góndola cerrada', + 'RO': 'Góndola abierta', + 'RS': 'Remolque de rack fijo / caída simple', + 'SD': 'Remolque de caída simple', + 'T1': 'Pipa / Tanque (gas) no caldeado / no aislado', + 'T2': 'Pipa / Tanque (gas) caldeado / no aislado', + 'T3': 'Pipa / Tanque (gas) no caldeado / aislado', + 'T4': 'Pipa / Tanque (gas) caldeado / aislado', + 'T5': 'Pipa / Tanque (químicos) no caldeado / no aislado', + 'T6': 'Pipa / Tanque (químicos) caldeado / no aislado', + 'T7': 'Pipa / Tanque (químicos) no caldeado / aislado', + 'T8': 'Pipa / Tanque (químicos) caldeado / aislado', + 'TC': 'Portavehículos / Nodriza', + 'TK': 'Pipa / Tanque (líquidos grado alimenticio)', + 'TL': 'Semirremolque', + 'TW': 'Remolque de temperatura controlada' +}; + +/** + * Obtiene la descripción traducida de un tipo de trailer. + * @param key Clave del tipo de trailer (ej: '20', 'FT') + * @param fallback Descripción original por si no hay traducción + * @returns La descripción en el idioma activo + */ +export function getTrailerTypeDescription(key: string, fallback: string = ''): string { + const locale = getLocale(); + if (locale.startsWith('es')) { + return TRAILER_TYPE_ES[key] || fallback; + } + return fallback; +} diff --git a/frontend/src/lib/i18n/vehicles.ts b/frontend/src/lib/i18n/vehicles.ts new file mode 100644 index 0000000..16a2efc --- /dev/null +++ b/frontend/src/lib/i18n/vehicles.ts @@ -0,0 +1,266 @@ +import { getLocale } from '$lib/paraglide/runtime'; + +type VehiclesLocale = 'es' | 'en'; + +const MESSAGES = { + es: { + shortcuts_scope: 'Vehículos (transporte)', + shortcuts_save: 'Guardar', + page_title: 'Vehículos (Transporte)', + page_subtitle: 'Gestión del catálogo de camiones y vehículos de transporte', + new_vehicle: 'Nuevo Vehículo', + vehicle_list: 'Listado de Vehículos', + search_key: 'Clave', + search_plate: 'Placas', + loading_vehicles: 'Cargando vehículos...', + showing_records: 'Mostrando {shown} de {total} registros', + edit: 'Editar', + delete: 'Eliminar', + delete_confirm: '¿Estás seguro de eliminar el vehículo "{key}"?\n\nNota: No se puede eliminar si tiene registros relacionados.', + delete_error_title: '❌ Error al eliminar:', + delete_success: '✅ Vehículo eliminado correctamente', + col_key: 'Clave', + col_brand: 'Marca', + col_year: 'Año', + col_plate: 'Placas', + col_transporter: 'Transportista', + col_transport_type: 'Tipo Transporte', + col_actions: 'Acciones', + menu_open: 'Abrir menú', + menu_actions: 'Acciones', + session_expired: 'Sesión expirada. Recargando página...', + no_company_error: '❌ Error: No hay una compañía seleccionada', + delete_generic_error: 'Error al eliminar', + delete_error_alert: '❌ Error: {error}', + delete_success_item: '✅ Vehículo "{key}" eliminado correctamente', + dialog_edit_title: 'Editar Vehículo', + dialog_new_title: 'Nuevo Vehículo', + dialog_edit_description: 'Modifica los datos del vehículo', + dialog_new_description: 'Completa los datos para crear un nuevo vehículo de transporte', + tab_general: 'Información General', + tab_details: 'Seguro y Detalles', + section_vehicle_identification: 'Identificación del Vehículo', + label_vehicle_key: 'Clave del Vehículo', + placeholder_vehicle_key: 'Ej: VH001', + label_brand: 'Marca', + placeholder_brand: 'Ej: Kenworth', + label_year: 'Año', + placeholder_year: 'YYYY', + label_plate_number: 'Placas', + placeholder_plate_number: 'Placas actuales', + label_series: 'Serie / VIN', + placeholder_series: 'Número de serie', + section_transport_data: 'Datos de Transporte', + label_transporter: 'Transportista', + select_transporter: '— Seleccionar transportista —', + empty: '— Vacío —', + label_transport_identifier: 'ID Transporte', + placeholder_transport_identifier: 'Identificador único', + label_transport_type: 'Tipo de Transporte', + optional: '— Opcional —', + label_entity_code: 'Código de Entidad', + no_code: '— Sin código —', + entity_required_hint: 'Obligatorio al crear (reglas CSV)', + label_sct_permission: 'Permiso SCT', + placeholder_sct_permission: 'Número de permiso', + section_insurance: 'Seguro y Otros', + label_insurance_company: 'Aseguradora', + placeholder_insurance_company: 'Nombre de la compañía', + label_insurance_number: 'Póliza', + placeholder_insurance_number: 'Número de póliza', + label_insurance_amount: 'Monto', + label_dot_number: 'Número DOT', + label_country: 'País (AME)', + label_state: 'Estado', + select_state: '— Selecciona —', + select_country_first: '— Primero el país —', + label_city: 'Ciudad', + placeholder_city: 'Ciudad/Localidad', + label_color: 'Color', + placeholder_color: 'Color del vehículo', + label_container_type: 'Tipo Contenedor', + placeholder_container_type: 'Ej: 40G', + section_location: 'Ubicación y Detalles', + label_additional_description: 'Descripción Adicional', + placeholder_additional_description: 'Notas adicionales sobre el vehículo...', + cancel: 'Cancelar', + saving: 'Guardando...', + update: 'Actualizar', + create: 'Crear', + no_company_selected: 'No hay una compañía seleccionada', + vehicle_key_required: 'La clave del vehículo es requerida', + save_error: 'Error al guardar el vehículo', + transport_types: { + AR: 'Camión Blindado', + AU: 'Automóviles', + BT: 'Camión de Caja', + BU: 'Autobús', + BV: 'Camión de Bebidas', + BY: 'Bicicleta', + CO: 'Vehículo de Construcción', + EV: 'Vehículo de Emergencia', + FE: 'Ferry', + FM: 'Tractor Agrícola', + GB: 'Camión de Basura', + MC: 'Motocicleta', + OC: 'Otro', + PM: 'Camioneta con cabina', + PN: 'Camión Panel', + PU: 'Camioneta (Pick-up)', + PV: 'Pasajero', + RV: 'Vehículo Recreativo (RV)', + TR: 'Tractocamión', + TV: 'Van' + } + }, + en: { + shortcuts_scope: 'Vehicles (transport)', + shortcuts_save: 'Save', + page_title: 'Vehicles (Transport)', + page_subtitle: 'Transport trucks and vehicles catalog management', + new_vehicle: 'New Vehicle', + vehicle_list: 'Vehicle List', + search_key: 'Key', + search_plate: 'Plates', + loading_vehicles: 'Loading vehicles...', + showing_records: 'Showing {shown} of {total} records', + edit: 'Edit', + delete: 'Delete', + delete_confirm: 'Are you sure you want to delete vehicle "{key}"?\n\nNote: It cannot be deleted if it has related records.', + delete_error_title: '❌ Error deleting:', + delete_success: '✅ Vehicle deleted successfully', + col_key: 'Key', + col_brand: 'Brand', + col_year: 'Year', + col_plate: 'Plates', + col_transporter: 'Transporter', + col_transport_type: 'Transport Type', + col_actions: 'Actions', + menu_open: 'Open menu', + menu_actions: 'Actions', + session_expired: 'Session expired. Reloading page...', + no_company_error: '❌ Error: No company selected', + delete_generic_error: 'Error deleting', + delete_error_alert: '❌ Error: {error}', + delete_success_item: '✅ Vehicle "{key}" deleted successfully', + dialog_edit_title: 'Edit Vehicle', + dialog_new_title: 'New Vehicle', + dialog_edit_description: 'Modify vehicle data', + dialog_new_description: 'Complete the data to create a new transport vehicle', + tab_general: 'General Information', + tab_details: 'Insurance and Details', + section_vehicle_identification: 'Vehicle Identification', + label_vehicle_key: 'Vehicle Key', + placeholder_vehicle_key: 'Eg: VH001', + label_brand: 'Brand', + placeholder_brand: 'Eg: Kenworth', + label_year: 'Year', + placeholder_year: 'YYYY', + label_plate_number: 'Plates', + placeholder_plate_number: 'Current plates', + label_series: 'Series / VIN', + placeholder_series: 'Serial number', + section_transport_data: 'Transport Data', + label_transporter: 'Transporter', + select_transporter: '— Select transporter —', + empty: '— Empty —', + label_transport_identifier: 'Transport ID', + placeholder_transport_identifier: 'Unique identifier', + label_transport_type: 'Transport Type', + optional: '— Optional —', + label_entity_code: 'Entity Code', + no_code: '— No code —', + entity_required_hint: 'Required on create (CSV rules)', + label_sct_permission: 'SCT Permit', + placeholder_sct_permission: 'Permit number', + section_insurance: 'Insurance and Others', + label_insurance_company: 'Insurance Company', + placeholder_insurance_company: 'Company name', + label_insurance_number: 'Policy', + placeholder_insurance_number: 'Policy number', + label_insurance_amount: 'Amount', + label_dot_number: 'DOT Number', + label_country: 'Country (AME)', + label_state: 'State', + select_state: '— Select —', + select_country_first: '— Select country first —', + label_city: 'City', + placeholder_city: 'City/Location', + label_color: 'Color', + placeholder_color: 'Vehicle color', + label_container_type: 'Container Type', + placeholder_container_type: 'Eg: 40G', + section_location: 'Location and Details', + label_additional_description: 'Additional Description', + placeholder_additional_description: 'Additional notes about the vehicle...', + cancel: 'Cancel', + saving: 'Saving...', + update: 'Update', + create: 'Create', + no_company_selected: 'No company selected', + vehicle_key_required: 'Vehicle key is required', + save_error: 'Error saving vehicle', + transport_types: { + AR: 'Armored Truck', + AU: 'Automobiles', + BT: 'Box Truck', + BU: 'Bus', + BV: 'Beverage Truck (Refer or not)', + BY: 'Bicycle', + CO: 'Construction Vehicle (general)', + EV: 'Emergency Vehicle (general)', + FE: 'Ferry', + FM: 'Farm Tractor', + GB: 'Garbage Truck', + MC: 'Motorcycle', + OC: 'Other', + PM: 'Pick-up Truck w/camper', + PN: 'Panel Truck', + PU: 'Pickup Truck', + PV: 'Passenger', + RV: 'Recreation Vehicle (RV)', + TR: 'Semi Tracker', + TV: 'Van' + } + } +} as const; + +export type VehiclesMessageKey = Exclude; + +function activeLocale(): VehiclesLocale { + if (typeof window !== 'undefined') { + const htmlLang = document.documentElement?.lang?.toLowerCase() || ''; + if (htmlLang.startsWith('es')) return 'es'; + if (htmlLang.startsWith('en')) return 'en'; + + const storedLocaleCandidates = [ + window.localStorage.getItem('locale'), + window.localStorage.getItem('lang'), + window.localStorage.getItem('language') + ] + .filter(Boolean) + .map((value) => String(value).toLowerCase()); + + if (storedLocaleCandidates.some((value) => value.startsWith('es'))) return 'es'; + if (storedLocaleCandidates.some((value) => value.startsWith('en'))) return 'en'; + } + + return getLocale().startsWith('es') ? 'es' : 'en'; +} + +export function tv(key: VehiclesMessageKey, vars?: Record): string { + const locale = activeLocale(); + let text = MESSAGES[locale][key] as string; + if (!vars) return text; + for (const [name, value] of Object.entries(vars)) { + text = text.replaceAll(`{${name}}`, String(value)); + } + return text; +} + +export function tvTransportType(code?: string, fallback?: string): string { + const normalizedCode = (code || '').trim().toUpperCase(); + if (!normalizedCode) return fallback || ''; + const locale = activeLocale(); + return MESSAGES[locale].transport_types[normalizedCode as keyof (typeof MESSAGES)['es']['transport_types']] || fallback || ''; +} diff --git a/frontend/src/lib/server/access-token-cookie.ts b/frontend/src/lib/server/access-token-cookie.ts new file mode 100644 index 0000000..19f279c --- /dev/null +++ b/frontend/src/lib/server/access-token-cookie.ts @@ -0,0 +1,55 @@ +import type { Cookies } from '@sveltejs/kit'; +import { + ACCESS_TOKEN_CHUNK_COUNT, + accessTokenChunkName, + splitAccessTokenForCookies, + ACCESS_TOKEN_MAX_CHUNKS +} from '$lib/access-token-cookie.shared'; + +export function getAccessTokenFromCookies(cookies: Cookies): string | null { + const countRaw = cookies.get(ACCESS_TOKEN_CHUNK_COUNT); + if (countRaw) { + const n = parseInt(countRaw, 10); + if (!Number.isFinite(n) || n < 1 || n > ACCESS_TOKEN_MAX_CHUNKS) return null; + let out = ''; + for (let i = 0; i < n; i++) { + const p = cookies.get(accessTokenChunkName(i)); + if (p == null) return null; + out += p; + } + return out; + } + return cookies.get('access_token') ?? null; +} + +export function clearAccessTokenCookies(cookies: Cookies) { + cookies.delete('access_token', { path: '/' }); + cookies.delete(ACCESS_TOKEN_CHUNK_COUNT, { path: '/' }); + for (let i = 0; i < ACCESS_TOKEN_MAX_CHUNKS; i++) { + cookies.delete(accessTokenChunkName(i), { path: '/' }); + } +} + +export function setAccessTokenCookies( + cookies: Cookies, + token: string, + opts: { secure: boolean; maxAge: number } +) { + clearAccessTokenCookies(cookies); + const split = splitAccessTokenForCookies(token); + const base = { + path: '/', + httpOnly: false as const, + sameSite: 'lax' as const, + secure: opts.secure, + maxAge: opts.maxAge + }; + if (split.kind === 'single') { + cookies.set('access_token', split.value, base); + return; + } + cookies.set(ACCESS_TOKEN_CHUNK_COUNT, String(split.parts.length), base); + split.parts.forEach((part, i) => { + cookies.set(accessTokenChunkName(i), part, base); + }); +} diff --git a/frontend/src/lib/server/api.ts b/frontend/src/lib/server/api.ts new file mode 100644 index 0000000..2c39118 --- /dev/null +++ b/frontend/src/lib/server/api.ts @@ -0,0 +1,465 @@ +/** + * Utilidades para llamadas a la API desde el servidor (SSR) + * Centraliza la lógica de configuración de URL, autenticación y manejo de tokens + */ + +import { redirect, type Cookies } from '@sveltejs/kit'; +import { + clearAccessTokenCookies, + getAccessTokenFromCookies, + setAccessTokenCookies +} from '$lib/server/access-token-cookie'; +import { isSecureContext } from '$lib/server/workspace-auth'; + +/** + * Obtiene y normaliza la URL base de la API para llamadas desde el servidor + * Automáticamente reemplaza localhost/127.0.0.1 con 'backend' para Docker + */ +export function getServerApiUrl(): string { + // Primero intentar con INTERNAL_API_URL (para llamadas server-side en Docker) + let apiUrl = process.env.INTERNAL_API_URL; + + // Si no está definida, usar VITE_API_URL del entorno runtime (no import.meta.env) + if (!apiUrl) { + apiUrl = process.env.VITE_API_URL; + } + + // Como último recurso, usar el valor de build-time + if (!apiUrl) { + apiUrl = import.meta.env.VITE_API_URL; + // Reemplazar 'localhost' con 'backend' para llamadas desde el servidor (SSR) + apiUrl = apiUrl?.replace('localhost', 'backend').replace('127.0.0.1', 'backend').replace('mi-app.dominio.com', 'backend'); + } + + // Normalizar la URL: asegurar que termine con '/' + return apiUrl?.endsWith('/') ? apiUrl : `${apiUrl}/`; +} + +/** + * Obtiene los tokens de autenticación de las cookies + */ +export function getAuthTokens(cookies: Cookies) { + return { + accessToken: getAccessTokenFromCookies(cookies), + refreshToken: cookies.get('refresh_token') + }; +} + +/** + * Establece los tokens de autenticación en las cookies + * + * Política de seguridad: + * - access_token → NO HttpOnly (Bearer desde JS); si el JWT es muy grande, varias cookies fragmentadas + * - refresh_token → HttpOnly=true (JS nunca lo lee; el servidor lo maneja via /api-sveltekit/auth/silent-refresh) + */ +export function setAuthTokens( + cookies: Cookies, + accessToken: string, + refreshToken?: string +) { + setAccessTokenCookies(cookies, accessToken, { + secure: isSecureContext(), + maxAge: 60 * 60 * 24 * 7 // 7 días + }); + + if (refreshToken) { + cookies.set('refresh_token', refreshToken, { + path: '/', + httpOnly: true, // *** HttpOnly: JS nunca lee el refresh_token *** + sameSite: 'lax', + secure: isSecureContext(), + maxAge: 60 * 60 * 24 * 30 // 30 días + }); + } +} + +/** + * Limpia todos los tokens de autenticación de las cookies + */ +export function clearAuthTokens(cookies: Cookies) { + clearAccessTokenCookies(cookies); + cookies.delete('refresh_token', { path: '/' }); + cookies.delete('id_token', { path: '/' }); + cookies.delete('active_company_id', { path: '/' }); + cookies.delete('active_system', { path: '/' }); +} + +/** + * Crea headers de autorización con el token Bearer + */ +export function createAuthHeaders(token: string, additionalHeaders?: Record, tenantOverride?: string, activeSystem?: string) { + return { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + ...(tenantOverride ? { 'X-Tenant-Override': tenantOverride } : {}), + ...(activeSystem ? { 'X-Active-System': activeSystem } : {}), + ...additionalHeaders + }; +} + +/** + * Intenta refrescar el token de acceso usando el refresh token + * @returns El nuevo access token o null si falla + */ +export async function refreshAccessToken( + cookies: Cookies, + fetch: typeof globalThis.fetch +): Promise { + const { refreshToken } = getAuthTokens(cookies); + + if (!refreshToken) { + return null; + } + + try { + const baseUrl = getServerApiUrl(); + const response = await fetch(`${baseUrl}v1/auth/refresh`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ refresh_token: refreshToken }) + }); + + if (!response.ok) { + return null; + } + + const data = await response.json(); + + // Actualizar las cookies con los nuevos tokens + setAuthTokens(cookies, data.access_token, data.refresh_token); + + return data.access_token; + } catch (error) { + console.error('🔄 [API] Error al refrescar token:', error); + return null; + } +} + +/** + * Realiza una petición autenticada a la API con manejo automático de refresh + * @param endpoint - Endpoint relativo (ej: 'v1/auth/me') + * @param options - Opciones de fetch + * @param cookies - Objeto de cookies de SvelteKit + * @param fetch - Función fetch de SvelteKit + * @param redirectUrl - URL a la que redirigir si falla la autenticación (opcional) + * @param timeout - Timeout en milisegundos (default: 30000ms) + */ +export async function authenticatedFetch( + endpoint: string, + options: RequestInit = {}, + cookies: Cookies, + fetch: typeof globalThis.fetch, + redirectUrl?: string, + timeout: number = 30000 +): Promise { + try { + const baseUrl = getServerApiUrl(); + let { accessToken } = getAuthTokens(cookies); + + // Si no hay token, redirigir o lanzar error + if (!accessToken) { + if (redirectUrl) { + throw redirect(303, redirectUrl); + } + throw new Error('No access token available'); + } + + // Construir URL completa + const url = endpoint.startsWith('http') ? endpoint : `${baseUrl}${endpoint}`; + + // Leer tenant override de cookie SSO (flujo multi-tenant relay) + const tenantOverride = cookies.get('sso_tenant_id'); + // Sistema activo (SCAF/SCAII) para reenviar al backend vía header + const activeSystem = cookies.get('active_system'); + + // Crear AbortController para timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + console.error(`⏱️ [API] Timeout después de ${timeout}ms:`, endpoint); + controller.abort(); + }, timeout); + + // Realizar la petición inicial + // Si el body es FormData, no incluir Content-Type (el navegador lo establece con el boundary) + const isFormData = options.body instanceof FormData; + const headers = isFormData + ? { + 'Authorization': `Bearer ${accessToken}`, + ...(activeSystem ? { 'X-Active-System': activeSystem } : {}), + ...(options.headers as Record || {}) + } + : createAuthHeaders(accessToken, options.headers as Record, tenantOverride, activeSystem); + + let response = await fetch(url, { + ...options, + headers, + signal: controller.signal + }); + + clearTimeout(timeoutId); + + // Si es 403, no intentar refrescar - es un problema de permisos + if (response.status === 403) { + console.warn('🚫 [API] Acceso denegado (403):', endpoint); + return response; // Retornar directamente para que el llamador maneje el error + } + + // Si es 401, intentar refrescar el token + if (response.status === 401) { + const newToken = await refreshAccessToken(cookies, fetch); + + if (newToken) { + // Reintentar la petición con el nuevo token + const newController = new AbortController(); + const newTimeoutId = setTimeout(() => { + console.error(`⏱️ [API] Timeout en retry después de ${timeout}ms:`, endpoint); + newController.abort(); + }, timeout); + + // Si el body es FormData, no incluir Content-Type + const newHeaders = isFormData + ? { + 'Authorization': `Bearer ${newToken}`, + ...(activeSystem ? { 'X-Active-System': activeSystem } : {}), + ...(options.headers as Record || {}) + } + : createAuthHeaders(newToken, options.headers as Record, tenantOverride, activeSystem); + + response = await fetch(url, { + ...options, + headers: newHeaders, + signal: newController.signal + }); + + clearTimeout(newTimeoutId); + } else { + // No se pudo refrescar, limpiar y redirigir + clearAuthTokens(cookies); + if (redirectUrl) { + throw redirect(303, redirectUrl); + } + } + } + + return response; + } catch (error) { + // Si es un redirect, re-lanzarlo + if (error && typeof error === 'object' && 'status' in error && 'location' in error) { + throw error; + } + + console.error('🔴 [API] Error en authenticatedFetch:', endpoint, error); + + // Retornar una respuesta de error simulada en lugar de lanzar + return new Response(JSON.stringify({ error: 'Network error', details: String(error) }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } +} + +/** + * Valida que el usuario esté autenticado y obtiene sus datos + * @returns Los datos del usuario o null si no está autenticado + */ +export async function validateAuth( + cookies: Cookies, + fetch: typeof globalThis.fetch, + redirectOnFail?: string +): Promise { + const pickAvatar = (...candidates: Array): string | null => { + for (const candidate of candidates) { + if (typeof candidate === 'string' && candidate.trim().length > 0) { + return candidate.trim(); + } + } + return null; + }; + + try { + const response = await authenticatedFetch( + 'v1/auth/me', + {}, + cookies, + fetch, + redirectOnFail + ); + + if (!response.ok) { + if (redirectOnFail) { + clearAuthTokens(cookies); + throw redirect(303, redirectOnFail); + } + return null; + } + + const keycloakData = await response.json(); + const workspaceAvatarFromAuthMe = pickAvatar( + keycloakData.avatar_url, + keycloakData.avatarUrl, + keycloakData.picture, + keycloakData.photo + ); + console.debug('[avatar][validateAuth] /v1/auth/me avatar_url recibido:', workspaceAvatarFromAuthMe ?? '(null)'); + + // Obtener perfil adicional del usuario (avatar, bio, etc.) + try { + const profileResponse = await authenticatedFetch( + 'v1/core/users/me/profile', + {}, + cookies, + fetch + ); + + if (profileResponse.ok) { + const profileData = await profileResponse.json(); + const workspaceAvatarFromProfile = pickAvatar( + profileData.workspaceAvatarUrl, + profileData.workspace_avatar_url + ); + const legacyAvatar = pickAvatar( + profileData.legacyAvatarUrl, + profileData.legacy_avatar_url, + profileData.avatarUrl, + profileData.avatar_url, + profileData.avatar, + profileData.photo, + profileData.picture + ); + const finalWorkspaceAvatar = pickAvatar(workspaceAvatarFromAuthMe, workspaceAvatarFromProfile); + const finalAvatar = pickAvatar(finalWorkspaceAvatar, legacyAvatar); + console.debug('[avatar][validateAuth] avatar final resuelto:', finalAvatar ?? '(null)'); + + // Combinar datos de Keycloak con datos del perfil. + // Prioridad para nombre: caché local del perfil > JWT claims. + return { + ...keycloakData, + id: profileData.id || keycloakData.id || keycloakData.sub, + username: profileData.username || keycloakData.username || keycloakData.preferred_username || '', + email: profileData.email || keycloakData.email || '', + first_name: profileData.first_name || keycloakData.first_name || keycloakData.given_name || '', + last_name: profileData.last_name || keycloakData.last_name || keycloakData.family_name || '', + avatar_url: finalAvatar, + avatarUrl: finalAvatar, + workspace_avatar_url: finalWorkspaceAvatar, + workspaceAvatarUrl: finalWorkspaceAvatar, + legacy_avatar_url: legacyAvatar, + legacyAvatarUrl: legacyAvatar, + phone: profileData.phone || null, + bio: profileData.bio || null, + preferences: profileData.preferences || {} + }; + } + } catch (profileError) { + console.warn('⚠️ [API] No se pudo cargar el perfil del usuario, usando solo datos de Keycloak'); + } + + // Fallback: map raw JWT claim names to the expected field names + const nameParts = (keycloakData.name || '').split(' '); + const finalAvatar = workspaceAvatarFromAuthMe; + console.debug('[avatar][validateAuth] fallback auth/me avatar final:', finalAvatar ?? '(null)'); + return { + ...keycloakData, + id: keycloakData.id || keycloakData.sub, + username: keycloakData.username || keycloakData.preferred_username || '', + first_name: keycloakData.first_name || keycloakData.given_name || nameParts[0] || '', + last_name: keycloakData.last_name || keycloakData.family_name || nameParts.slice(1).join(' ') || '', + avatar_url: finalAvatar, + avatarUrl: finalAvatar, + workspace_avatar_url: finalAvatar, + workspaceAvatarUrl: finalAvatar, + }; + } catch (error) { + // Si es un redirect, re-lanzarlo + if (error && typeof error === 'object' && 'status' in error && 'location' in error) { + throw error; + } + + console.error('🔐 [API] Error validando autenticación:', error); + + if (redirectOnFail) { + clearAuthTokens(cookies); + throw redirect(303, redirectOnFail); + } + + return null; + } +} + +/** + * Obtiene las compañías del usuario autenticado + */ +export async function getUserCompanies( + cookies: Cookies, + fetch: typeof globalThis.fetch +): Promise { + try { + const response = await authenticatedFetch( + 'v1/auth/my-companies', + {}, + cookies, + fetch + ); + + if (!response.ok) { + console.error('🏢 [API] Error cargando compañías:', response.status); + return []; + } + + return await response.json(); + } catch (error) { + console.error('🏢 [API] Error cargando compañías:', error); + return []; + } +} + +/** + * Obtiene el ID de la compañía activa, o la primera disponible si no hay ninguna seleccionada + */ +export async function getActiveCompanyId( + cookies: Cookies, + fetch: typeof globalThis.fetch +): Promise { + let companyId = cookies.get('active_company_id'); + + // Si no hay companyId en cookie, obtener las compañías del usuario y usar la primera + if (!companyId) { + const companies = await getUserCompanies(cookies, fetch); + if (companies.length > 0) { + companyId = companies[0].id.toString(); + } + } + + return companyId || null; +} + +/** + * Helper para manejar respuestas de API y convertir errores 403 en formato adecuado + * para mostrar toasts en el cliente + */ +export async function handleApiResponse( + response: Response +): Promise<{ data?: T; error?: { detail: string; status: number; isForbidden?: boolean } }> { + if (response.ok) { + // Para respuestas sin contenido (204) + if (response.status === 204) { + return { data: null as T }; + } + + const data = await response.json(); + return { data }; + } + + // Manejar errores + const errorData = await response.json().catch(() => ({ detail: 'Error desconocido' })); + + const error = { + detail: errorData.detail || errorData.message || 'Error en la petición', + status: response.status, + isForbidden: response.status === 403 + }; + + return { error }; +} diff --git a/frontend/src/lib/server/system-gate.ts b/frontend/src/lib/server/system-gate.ts new file mode 100644 index 0000000..8bc2238 --- /dev/null +++ b/frontend/src/lib/server/system-gate.ts @@ -0,0 +1,130 @@ +import { redirect, type Cookies } from '@sveltejs/kit'; +import type { SystemType } from '$lib/stores/system.svelte'; +import { authenticatedFetch } from '$lib/server/api'; +import { getWorkspaceBaseUrl } from '$lib/server/workspace-auth'; + +const VALID_SYSTEMS = new Set(['fixed_asset', 'inventory']); + +export function isValidSystem(value: string | null | undefined): value is SystemType { + return typeof value === 'string' && VALID_SYSTEMS.has(value as SystemType); +} + +function parseSystemsArray(raw: unknown): SystemType[] { + if (!Array.isArray(raw)) return []; + return raw.filter((s): s is SystemType => typeof s === 'string' && isValidSystem(s)); +} + +/** Decodifica el payload del JWT (sin verificar firma; el token ya fue validado vía Hub). */ +export function decodeJwtPayload(token: string): Record | null { + try { + const parts = token.split('.'); + if (parts.length < 2) return null; + const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const padded = payload + '='.repeat((4 - (payload.length % 4)) % 4); + const json = Buffer.from(padded, 'base64').toString('utf8'); + return JSON.parse(json) as Record; + } catch { + return null; + } +} + +/** Combina claims del JWT con la respuesta de /auth/me (Hub). */ +export function mergeTokenClaims( + userData: Record | null | undefined, + accessToken: string +): Record { + const jwtClaims = decodeJwtPayload(accessToken) ?? {}; + return { ...jwtClaims, ...(userData ?? {}) }; +} + +/** + * Sistemas permitidos según el token (claim `allowed_systems`). + * El Hub/Keycloak lo incluye cuando el usuario entra desde Workspace. + */ +export function extractAllowedSystemsFromToken( + tokenClaims: Record | null | undefined +): SystemType[] { + if (!tokenClaims) return []; + return parseSystemsArray(tokenClaims.allowed_systems ?? tokenClaims.allowedSystems); +} + +export function setActiveSystemCookie(cookies: Cookies, system: SystemType) { + cookies.set('active_system', system, { + path: '/', + maxAge: 60 * 60 * 24 * 30, // 30 días + sameSite: 'lax', + httpOnly: false, + secure: process.env.NODE_ENV === 'production' + }); +} + +export function resolveActiveCompanyId( + cookies: Cookies, + companies: T[] +): number | null { + const cookieCompanyId = cookies.get('active_company_id'); + if (cookieCompanyId) { + const cookieId = Number.parseInt(cookieCompanyId, 10); + if (Number.isFinite(cookieId) && companies.some((c) => c.id === cookieId)) return cookieId; + } + return companies.length > 0 ? companies[0].id : null; +} + +/** Permisos RBAC por compañía (fallback / validación en set-active). */ +export async function fetchAllowedSystems( + cookies: Cookies, + fetch: typeof globalThis.fetch, + companyId: number +): Promise { + const res = await authenticatedFetch( + `v1/core/permissions/me?company_id=${companyId}`, + { method: 'GET' }, + cookies, + fetch + ); + if (!res.ok) return []; + const data = (await res.json()) as { allowed_systems?: unknown }; + return parseSystemsArray(data.allowed_systems); +} + +export type SystemGateResult = + | { action: 'redirect_workspace' } + | { action: 'proceed'; activeSystem: SystemType }; + +/** + * Gate obligatorio. Prioridad: + * 1. requestedSystem del URL (Hub tiene autoridad — viene del relay firmado one-time) + * 2. cookieSystem validado contra allowedSystems del JWT + * 3. Primer sistema de allowedSystems del JWT + * 4. redirect_workspace si nada resuelve + * + * requestedSystem se acepta incluso si el JWT no trae allowed_systems (Keycloak sin claim): + * el backend valida RBAC en cada request de API. + */ +export function resolveSystemGate(params: { + tokenClaims: Record | null | undefined; + cookieSystem?: string | null; + requestedSystem?: string | null; +}): SystemGateResult { + const requestedSystem = params.requestedSystem; + if (isValidSystem(requestedSystem)) { + return { action: 'proceed', activeSystem: requestedSystem }; + } + + const allowedSystems = extractAllowedSystemsFromToken(params.tokenClaims); + + const cookieSystem = params.cookieSystem; + if (isValidSystem(cookieSystem)) { + return { action: 'proceed', activeSystem: cookieSystem }; + } + + if (allowedSystems.length > 0) { + return { action: 'proceed', activeSystem: allowedSystems[0] }; + } + + return { action: 'redirect_workspace' }; +} + +export function redirectToWorkspaceBase(): never { + throw redirect(303, getWorkspaceBaseUrl()); +} diff --git a/frontend/src/lib/server/workspace-apps.ts b/frontend/src/lib/server/workspace-apps.ts new file mode 100644 index 0000000..0bdb7ca --- /dev/null +++ b/frontend/src/lib/server/workspace-apps.ts @@ -0,0 +1,105 @@ +/** + * Consulta al Hub las aplicaciones del Workspace accesibles al usuario autenticado. + * + * Endpoint: GET /api/v1/auth/my-apps + * Respuesta (top-level): { routing: string, apps: [...] } + * - routing: decisión de routing del Hub (no se usa para redirigir; siempre se muestra el + * launcher para que el usuario elija). + * - apps: productos del Workspace accesibles, cada uno con su URL. + * + * Mismo patrón que la llamada a /api/v1/auth/my-tenants en +layout.server.ts. + */ + +import { dev } from '$app/environment'; +import { env } from '$env/dynamic/private'; +import type { WorkspaceApp } from '$lib/stores/workspace-apps.svelte'; + +export type MyAppsResponse = { + routing: string; + apps: WorkspaceApp[]; +}; + +const EMPTY_RESPONSE: MyAppsResponse = { routing: '', apps: [] }; + +function resolveHubUrl(): string { + return (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, ''); +} + +/** Item crudo de `apps` tal como lo devuelve el Hub en /api/v1/auth/my-apps. */ +type RawWorkspaceApp = { + id?: number | string; + name?: string; + slug?: string; + login_url?: string; + sso_url?: string; + image_url?: string | null; + description?: string | null; +}; + +/** Primer valor string no vacío. */ +function firstNonEmpty(...values: (string | null | undefined)[]): string | null { + for (const value of values) { + if (typeof value === 'string' && value.trim()) return value; + } + return null; +} + +/** Normaliza un item crudo de `apps` (shape del Hub) a WorkspaceApp para el launcher. */ +function normalizeWorkspaceApp(raw: unknown): WorkspaceApp | null { + if (!raw || typeof raw !== 'object') return null; + const app = raw as RawWorkspaceApp; + + // URL de entrada de la app (login_url). Para apps del mismo origin trae el `active_system` + // que usa el launcher para el cambio de sistema local; sso_url solo como respaldo. + const url = firstNonEmpty(app.login_url, app.sso_url); + if (!url) return null; // sin URL la app no es accionable en el launcher + + const id = app.id != null ? String(app.id) : (firstNonEmpty(app.slug) ?? url); + + return { + id, + name: firstNonEmpty(app.name, app.slug) ?? id, + slug: firstNonEmpty(app.slug), + url, + iconUrl: firstNonEmpty(app.image_url) + }; +} + +/** + * Obtiene las apps del Workspace usando el token del usuario. + * Nunca lanza: ante cualquier fallo degrada a respuesta vacía para no romper el dashboard + * (mismo criterio que la carga de tenants). + */ +export async function fetchMyApps( + accessToken: string, + fetch: typeof globalThis.fetch, + tenantOverride?: string | null +): Promise { + try { + const hubUrl = resolveHubUrl(); + const res = await fetch(`${hubUrl}/api/v1/auth/my-apps`, { + headers: { + Authorization: `Bearer ${accessToken}`, + ...(tenantOverride ? { 'X-Tenant-Override': tenantOverride } : {}) + } + }); + if (!res.ok) { + if (dev) console.log(`[my-apps] Hub respondió status ${res.status} en ${hubUrl} → sin apps`); + return EMPTY_RESPONSE; + } + + const data = (await res.json()) as { routing?: unknown; apps?: unknown }; + + const apps = Array.isArray(data.apps) + ? data.apps.map(normalizeWorkspaceApp).filter((app): app is WorkspaceApp => app !== null) + : []; + + return { + routing: typeof data.routing === 'string' ? data.routing : '', + apps + }; + } catch (error) { + if (dev) console.log('[my-apps] error llamando al Hub:', error); + return EMPTY_RESPONSE; + } +} diff --git a/frontend/src/lib/server/workspace-auth.ts b/frontend/src/lib/server/workspace-auth.ts new file mode 100644 index 0000000..cc94854 --- /dev/null +++ b/frontend/src/lib/server/workspace-auth.ts @@ -0,0 +1,275 @@ +import { env } from '$env/dynamic/private'; +import { redirect, type Cookies } from '@sveltejs/kit'; + +const DEFAULT_WORKSPACE_BASE_URL = 'https://workspace.aduanasoft.com'; +const RETURN_PATH_COOKIE = 'workspace_return_path'; + +/** + * Returns true only when the public-facing URL uses HTTPS. + * Use this for cookie `secure` flag instead of NODE_ENV so that + * cookies work on HTTP LAN dev environments (e.g. 192.168.x.x). + */ +export function isSecureContext(): boolean { + const origin = (env.ORIGIN || process.env.ORIGIN || '').trim(); + if (origin) return origin.startsWith('https://'); + return process.env.NODE_ENV === 'production'; +} + +function stripTrailingSlashes(value: string): string { + return value.replace(/\/+$/, ''); +} + +/** + * Detecta si una URL apunta a un host que solo es accesible localmente: + * localhost, 127.0.0.1, IPs de red LAN/privada y hostnames internos de Docker. + * Estas URLs no son válidas como redirect_uri ni como KC public URL en producción. + */ +function isDevOnlyUrl(rawUrl: string): boolean { + try { + const parsed = new URL(rawUrl); + const host = parsed.hostname.toLowerCase(); + return ( + host === 'localhost' || + host === '127.0.0.1' || + host === 'host.docker.internal' || + host === 'backend' || + host === 'hub-keycloak' || + /^192\.168\./.test(host) || + /^10\./.test(host) || + /^172\.(1[6-9]|2\d|3[01])\./.test(host) + ); + } catch { + return false; + } +} + +export function getWorkspaceBaseUrl(): string { + const candidates = [ + (env.VITE_HUB_URL || '').trim(), + (env.HUB_URL || '').trim(), + DEFAULT_WORKSPACE_BASE_URL + ].filter(Boolean); + + for (const candidate of candidates) { + if (!isDevOnlyUrl(candidate)) { + return stripTrailingSlashes(candidate); + } + } + + return DEFAULT_WORKSPACE_BASE_URL; +} + +/** + * Normaliza la URL base del sistema (Mi Aplicación) para construir redirect_uri seguros. + * + * Problema habitual en producción: SvelteKit deriva `url.origin` de la variable de entorno + * `ORIGIN`. Si el contenedor se despliega con `ORIGIN=http://localhost:5173` (valor del .env + * de dev), todos los redirect_uri generados por el servidor apuntan a localhost. + * + * Esta función: + * 1. Usa `requestOrigin` si ya es una URL pública (no dev-only). + * 2. Si es localhost, busca `SITE_URL` (env var de producción recomendada) como fallback. + * 3. Como último recurso devuelve requestOrigin tal cual (entorno dev genuino). + * + * Var de entorno recomendada en producción: + * SITE_URL=https://mi-app.dominio.com (además de arreglar ORIGIN) + */ +export function resolveSystemBaseUrl(requestOrigin: string): string { + if (!isDevOnlyUrl(requestOrigin)) { + return stripTrailingSlashes(requestOrigin); + } + + // requestOrigin es dev-only → ORIGIN env var apunta a localhost en producción. + // Buscar URL pública en env vars adicionales. + const candidates = [ + (env.SITE_URL || '').trim(), + (env.APP_URL || '').trim(), + (env.PUBLIC_URL || '').trim(), + ]; + + for (const candidate of candidates) { + if (candidate && !isDevOnlyUrl(candidate)) { + return stripTrailingSlashes(candidate); + } + } + + // Entorno dev genuino: devolver requestOrigin tal cual + return stripTrailingSlashes(requestOrigin); +} + +export type WorkspaceLoginUrlOptions = { + /** + * URL del login del Hub sin `return_to`. Usar en `post_logout_redirect_uri` para que, + * tras logout en KC, el Hub aplique myApps() (launcher si el usuario tiene varias apps). + * Con `return_to` a Mi Aplicación, el re-login siempre rebotaba a esa app aunque hubiera más. + */ + forPostLogout?: boolean; +}; + +export function getWorkspaceLoginUrl( + systemBaseUrl: string, + options?: WorkspaceLoginUrlOptions +): string { + const workspaceBaseUrl = getWorkspaceBaseUrl(); + if (options?.forPostLogout) { + return `${workspaceBaseUrl}/login`; + } + // return_to includes sso_verified=1 so the workspace preserves it when redirecting + // back, regardless of what additional params the workspace appends. + const loginUrl = `${systemBaseUrl}/login?sso_verified=1`; + return `${workspaceBaseUrl}/login?return_to=${encodeURIComponent(loginUrl)}`; +} + +export function storeReturnPath(cookies: Cookies, path: string): void { + if (!path || !path.startsWith('/')) return; + cookies.set(RETURN_PATH_COOKIE, path, { + path: '/', + httpOnly: true, + sameSite: 'lax', + secure: isSecureContext(), + maxAge: 60 * 10 + }); +} + +export function getPublicKeycloakBaseUrl(): string { + const configuredKeycloakUrl = (env.VITE_KEYCLOAK_URL || '').trim(); + // Si VITE_KEYCLOAK_URL apunta a un host dev-only (localhost, IP LAN, Docker service), + // ignorarlo y derivar la URL del hostname público del Workspace. + // Esto protege contra builds donde el .env de dev llega a producción por error. + if (configuredKeycloakUrl && !isDevOnlyUrl(configuredKeycloakUrl)) { + return stripTrailingSlashes(configuredKeycloakUrl); + } + + return `${getWorkspaceBaseUrl()}/kcauth`; +} + +export function getKeycloakRealm(): string { + return (env.KEYCLOAK_REALM || env.VITE_KEYCLOAK_REALM || 'master').trim(); +} + +export function getKeycloakClientId(): string { + return (env.KEYCLOAK_CLIENT_ID || env.VITE_KEYCLOAK_CLIENT_ID || 'app-frontend').trim(); +} + +export function getCleanReturnPath(url: URL): string { + const cleanParams = new URLSearchParams(url.searchParams); + cleanParams.delete('sso_verified'); + + const queryString = cleanParams.toString(); + return queryString ? `${url.pathname}?${queryString}` : url.pathname; +} + +export function storeWorkspaceReturnPath(cookies: Cookies, url: URL): string { + const returnPath = getCleanReturnPath(url); + + cookies.set(RETURN_PATH_COOKIE, returnPath, { + path: '/', + httpOnly: true, + sameSite: 'lax', + secure: isSecureContext(), + maxAge: 60 * 10 + }); + + return returnPath; +} + +export function readWorkspaceReturnPath(cookies: Cookies, fallbackPath: string): string { + const storedReturnPath = cookies.get(RETURN_PATH_COOKIE); + if (storedReturnPath && storedReturnPath.startsWith('/')) { + return storedReturnPath; + } + + return fallbackPath; +} + +export function clearWorkspaceReturnPath(cookies: Cookies): void { + cookies.delete(RETURN_PATH_COOKIE, { path: '/' }); +} + +export function buildKeycloakAuthorizationUrl(systemBaseUrl: string, redirectPath: string): string { + const keycloakBaseUrl = getPublicKeycloakBaseUrl(); + // resolveSystemBaseUrl corrige el caso donde url.origin es localhost por ORIGIN env var mal configurado + const publicBase = resolveSystemBaseUrl(systemBaseUrl); + const redirectUri = `${publicBase}/auth/callback`; + const state = JSON.stringify({ redirect_url: redirectPath }); + const params = new URLSearchParams({ + client_id: getKeycloakClientId(), + redirect_uri: redirectUri, + response_type: 'code', + scope: 'openid', + prompt: 'none', + state + }); + + return `${keycloakBaseUrl}/realms/${getKeycloakRealm()}/protocol/openid-connect/auth?${params.toString()}`; +} + +/** + * Construye URL de login directo en KC sin prompt=none. + * Usa la sesión KC existente si la hay; si no, muestra el form de login. + * Usar cuando se recibe ?redirect= del Hub (rompe el loop Hub↔login). + */ +export function buildKeycloakLoginUrl(systemBaseUrl: string, redirectPath: string): string { + const keycloakBaseUrl = getPublicKeycloakBaseUrl(); + // resolveSystemBaseUrl corrige el caso donde url.origin es localhost por ORIGIN env var mal configurado + const publicBase = resolveSystemBaseUrl(systemBaseUrl); + const redirectUri = `${publicBase}/auth/callback`; + const state = JSON.stringify({ redirect_url: redirectPath }); + const params = new URLSearchParams({ + client_id: getKeycloakClientId(), + redirect_uri: redirectUri, + response_type: 'code', + scope: 'openid', + state + }); + + return `${keycloakBaseUrl}/realms/${getKeycloakRealm()}/protocol/openid-connect/auth?${params.toString()}`; +} + +export function redirectToWorkspaceLogin(cookies: Cookies, url: URL): never { + // Modo local: nunca salir al workspace, mostrar el login local. + if ((env.DEV_LOCAL_AUTH ?? '').toLowerCase() === 'true') { + throw redirect(303, '/login'); + } + storeWorkspaceReturnPath(cookies, url); + throw redirect(303, getWorkspaceLoginUrl(url.origin)); +} + +export function redirectToKeycloakAuthorization(systemBaseUrl: string, redirectPath: string): never { + throw redirect(303, buildKeycloakAuthorizationUrl(systemBaseUrl, redirectPath)); +} + +export function redirectToKeycloakLogin(systemBaseUrl: string, redirectPath: string): never { + throw redirect(303, buildKeycloakLoginUrl(systemBaseUrl, redirectPath)); +} + +/** + * URL del Hub FastAPI para llamadas server-to-server (ej. sso-exchange). + * No aplica isDevOnlyUrl: las URLs internas de Docker son válidas aquí. + * Lee HUB_BACKEND_URL (override explícito) → INTERNAL_HUB_URL (ya en docker-compose) + * → fallback a URL pública del workspace (vía proxy SvelteKit del Hub). + */ +export function getHubBackendUrl(): string { + const direct = + (env.HUB_BACKEND_URL || '').trim() || + (env.INTERNAL_HUB_URL || '').trim(); + if (direct) return stripTrailingSlashes(direct); + return getWorkspaceBaseUrl(); +} + +export function buildKeycloakLogoutUrl(systemBaseUrl: string, idTokenHint?: string): string { + const keycloakBaseUrl = getPublicKeycloakBaseUrl(); + const postLogoutRedirectUri = `${systemBaseUrl}/auth/post-logout`; + const params = new URLSearchParams({ + client_id: getKeycloakClientId(), + post_logout_redirect_uri: postLogoutRedirectUri + }); + + // Con id_token_hint KC acepta cualquier post_logout_redirect_uri sin necesidad + // de que esté registrado explícitamente en el cliente. + if (idTokenHint) { + params.set('id_token_hint', idTokenHint); + } + + return `${keycloakBaseUrl}/realms/${getKeycloakRealm()}/protocol/openid-connect/logout?${params.toString()}`; +} \ No newline at end of file diff --git a/frontend/src/lib/session-manager.ts b/frontend/src/lib/session-manager.ts new file mode 100644 index 0000000..b9157e8 --- /dev/null +++ b/frontend/src/lib/session-manager.ts @@ -0,0 +1,505 @@ +/** + * Gestor de sesión SSO para Keycloak + * + * Responsabilidades: + * - Refresh silencioso del access token SOLO cuando el usuario está activo. + * - Detección de actividad del usuario (evita polling innecesario cuando está idle). + * - Idle timeout: si el usuario está inactivo, no refrescar → el token y la sesión + * expiran en Keycloak de forma natural → siguiente petición 401 → logout. + * - Logout automático cuando el refresh falla (sesión SSO terminada por Keycloak, + * admin forzado, max session alcanzado, etc.). + * - Verificación de sesión SSO usando el iframe silencioso de Keycloak JS. + * + * Flujo de tokens: + * - Access token: en memoria (authStore) + cookie no-HttpOnly (password login) + * o en instancia Keycloak JS (SSO flow) + * - Refresh token: cookie HttpOnly únicamente (el JS nunca lo toca) + * - El refresh se hace server-side via /api-sveltekit/auth/silent-refresh + */ + +import { browser } from '$app/environment'; +import type Keycloak from 'keycloak-js'; + +// ───────────────────────────────────────────────────────── +// Eventos DOM personalizados +// ───────────────────────────────────────────────────────── + +/** Se emite cuando la sesión está próxima a expirar por inactividad */ +export const SESSION_WARNING_EVENT = 'session:warning'; +/** Se emite cuando la sesión ha expirado (idle, max-session o refresh fallido) */ +export const SESSION_EXPIRED_EVENT = 'session:expired'; +/** Se emite cuando el usuario extiende la sesión desde el diálogo de advertencia */ +export const SESSION_EXTENDED_EVENT = 'session:extended'; +/** Se emite después de un refresh silencioso exitoso */ +export const SESSION_TOKEN_REFRESHED_EVENT = 'session:token-refreshed'; + +// ───────────────────────────────────────────────────────── +// Tipos +// ───────────────────────────────────────────────────────── + +export type SessionExpiredReason = 'idle' | 'refresh_failed' | 'keycloak_session_ended' | 'error' | 'manual'; + +export interface SessionExpiredDetail { + reason: SessionExpiredReason; +} + +export interface SessionWarningDetail { + remainingMs: number; +} + +export interface SessionManagerOptions { + /** + * Segundos antes de la expiración del token para intentar el refresh. + * Default: 60 + */ + refreshBeforeExpirySeconds?: number; + + /** + * Tiempo de inactividad (ms) después del cual NO se refresca el token, + * permitiendo que la sesión de Keycloak expire de forma natural. + * Default: 30 minutos (1_800_000 ms) + */ + idleTimeoutMs?: number; + + /** + * Milisegundos antes del idle timeout para mostrar el diálogo de advertencia. + * Default: 5 minutos (300_000 ms) + */ + warningBeforeIdleMs?: number; + + /** + * Intervalo (ms) para verificar silenciosamente la sesión SSO de Keycloak. + * Solo se usa cuando hay una instancia de Keycloak JS autenticada. + * Default: 5 minutos (300_000 ms). 0 para deshabilitar. + */ + ssoCheckIntervalMs?: number; + + /** Función para obtener la instancia de Keycloak JS (si usa el SSO flow) */ + getKeycloakInstance?: () => Keycloak | null; + + /** Callback cuando el token se refresca exitosamente */ + onTokenRefreshed?: (newToken: string) => void; + + /** Callback cuando la sesión expira */ + onSessionExpired?: (reason: SessionExpiredReason) => void; +} + +// ───────────────────────────────────────────────────────── +// SessionManager +// ───────────────────────────────────────────────────────── + +export class SessionManager { + private opts: Required; + + // Timers + private refreshTimerId: ReturnType | null = null; + private idleTimerId: ReturnType | null = null; + private warningTimerId: ReturnType | null = null; + private ssoCheckIntervalId: ReturnType | null = null; + + // State + private currentToken: string | null = null; + private lastActivityAt = Date.now(); + private warningShown = false; + private isRefreshing = false; + private destroyed = false; + + // Activity listener cleanups + private removeListeners: Array<() => void> = []; + + constructor(options: SessionManagerOptions = {}) { + this.opts = { + refreshBeforeExpirySeconds: options.refreshBeforeExpirySeconds ?? 60, + idleTimeoutMs: options.idleTimeoutMs ?? 30 * 60 * 1000, + warningBeforeIdleMs: options.warningBeforeIdleMs ?? 5 * 60 * 1000, + ssoCheckIntervalMs: options.ssoCheckIntervalMs ?? 5 * 60 * 1000, + getKeycloakInstance: options.getKeycloakInstance ?? (() => null), + onTokenRefreshed: options.onTokenRefreshed ?? (() => {}), + onSessionExpired: options.onSessionExpired ?? (() => {}) + }; + } + + // ───────────────────────────────────────────────────── + // Public API + // ───────────────────────────────────────────────────── + + /** + * Inicia el gestor de sesión con el token actual. + * Debe llamarse una vez tras la autenticación exitosa. + */ + start(initialToken: string): void { + if (!browser || this.destroyed) return; + + this.currentToken = initialToken; + this.lastActivityAt = Date.now(); + this.warningShown = false; + + this.setupActivityListeners(); + this.scheduleRefresh(initialToken); + this.scheduleIdleTimers(); + this.startSsoCheckInterval(); + } + + /** + * Actualiza el token en memoria (llamar después de un refresh exitoso externo). + */ + updateToken(newToken: string): void { + if (this.destroyed) return; + this.currentToken = newToken; + this.warningShown = false; + this.cancelRefreshTimer(); + this.scheduleRefresh(newToken); + this.resetIdleTimers(); + } + + /** + * El usuario hizo clic en "Continuar sesión" en el diálogo de advertencia. + * Fuerza un refresh inmediato y reinicia los timers de inactividad. + */ + extendSession(): void { + if (this.destroyed) return; + this.recordActivity(); + if (this.currentToken) { + void this.doRefresh('extend'); + } + } + + /** Destruye el gestor y limpia todos los recursos. */ + destroy(): void { + this.destroyed = true; + this.cancelRefreshTimer(); + this.cancelIdleTimers(); + this.stopSsoCheckInterval(); + this.teardownActivityListeners(); + } + + // ───────────────────────────────────────────────────── + // Activity tracking + // ───────────────────────────────────────────────────── + + private setupActivityListeners(): void { + const events: (keyof WindowEventMap)[] = [ + 'mousedown', + 'mousemove', + 'keydown', + 'scroll', + 'touchstart', + 'click', + 'pointerdown' + ]; + + // Limitar actualizaciones de actividad a máximo una por segundo + let debounceTimer: ReturnType | null = null; + + const handler = () => { + if (debounceTimer) return; + debounceTimer = setTimeout(() => { + debounceTimer = null; + this.recordActivity(); + }, 1000); + }; + + events.forEach((event) => { + window.addEventListener(event, handler, { passive: true }); + this.removeListeners.push(() => window.removeEventListener(event, handler)); + }); + + // Al volver a la pestaña, registrar actividad y verificar si hace falta + // un refresh inmediato (el tiempo puede haber pasado con la pestaña en segundo plano) + const visibilityHandler = () => { + if (document.visibilityState === 'visible') { + this.recordActivity(); + void this.refreshIfExpiringSoon(); + } + }; + document.addEventListener('visibilitychange', visibilityHandler); + this.removeListeners.push(() => + document.removeEventListener('visibilitychange', visibilityHandler) + ); + } + + private teardownActivityListeners(): void { + this.removeListeners.forEach((fn) => fn()); + this.removeListeners = []; + } + + private recordActivity(): void { + const wasIdle = this.isIdle(); + this.lastActivityAt = Date.now(); + + if (this.warningShown) { + // El usuario volvió activo → descartar advertencia + this.warningShown = false; + this.resetIdleTimers(); + window.dispatchEvent(new CustomEvent(SESSION_EXTENDED_EVENT)); + } else if (wasIdle) { + // Volvemos de idle → reiniciar timers + this.resetIdleTimers(); + void this.refreshIfExpiringSoon(); + } + } + + private isIdle(): boolean { + return Date.now() - this.lastActivityAt > this.opts.idleTimeoutMs; + } + + // ───────────────────────────────────────────────────── + // Token refresh scheduling + // ───────────────────────────────────────────────────── + + private parseExpiry(token: string): number | null { + try { + const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))); + return typeof payload.exp === 'number' ? payload.exp * 1000 : null; + } catch { + return null; + } + } + + private scheduleRefresh(token: string): void { + const expiry = this.parseExpiry(token); + if (!expiry) return; + + const msUntilRefresh = expiry - Date.now() - this.opts.refreshBeforeExpirySeconds * 1000; + + if (msUntilRefresh <= 0) { + void this.doRefresh('scheduled'); + return; + } + + this.refreshTimerId = setTimeout(() => { + if (!this.destroyed) void this.doRefresh('scheduled'); + }, msUntilRefresh); + } + + private cancelRefreshTimer(): void { + if (this.refreshTimerId !== null) { + clearTimeout(this.refreshTimerId); + this.refreshTimerId = null; + } + } + + /** Refresca el token si le quedan menos de `refreshBeforeExpirySeconds` */ + private async refreshIfExpiringSoon(): Promise { + if (!this.currentToken) return; + const expiry = this.parseExpiry(this.currentToken); + if (!expiry) return; + if (expiry - Date.now() < this.opts.refreshBeforeExpirySeconds * 1000) { + await this.doRefresh('on-demand'); + } + } + + // ───────────────────────────────────────────────────── + // Idle session timers + // ───────────────────────────────────────────────────── + + private scheduleIdleTimers(): void { + this.cancelIdleTimers(); + const now = Date.now(); + const idleAt = this.lastActivityAt + this.opts.idleTimeoutMs; + const warnAt = idleAt - this.opts.warningBeforeIdleMs; + + const msUntilWarn = warnAt - now; + const msUntilIdle = idleAt - now; + + if (msUntilWarn > 0) { + this.warningTimerId = setTimeout(() => { + if (!this.destroyed && !this.warningShown && this.isIdle() === false) { + this.showWarning(this.opts.warningBeforeIdleMs); + } + }, msUntilWarn); + } + + if (msUntilIdle > 0) { + this.idleTimerId = setTimeout(() => { + if (!this.destroyed && this.isIdle()) { + this.handleIdleExpiry(); + } + }, msUntilIdle); + } + } + + private cancelIdleTimers(): void { + if (this.warningTimerId !== null) { + clearTimeout(this.warningTimerId); + this.warningTimerId = null; + } + if (this.idleTimerId !== null) { + clearTimeout(this.idleTimerId); + this.idleTimerId = null; + } + } + + private resetIdleTimers(): void { + this.scheduleIdleTimers(); + } + + private showWarning(remainingMs: number): void { + this.warningShown = true; + const detail: SessionWarningDetail = { remainingMs }; + window.dispatchEvent(new CustomEvent(SESSION_WARNING_EVENT, { detail })); + } + + private handleIdleExpiry(): void { + const detail: SessionExpiredDetail = { reason: 'idle' }; + window.dispatchEvent(new CustomEvent(SESSION_EXPIRED_EVENT, { detail })); + this.opts.onSessionExpired('idle'); + } + + // ───────────────────────────────────────────────────── + // Periodic Keycloak SSO session check (iframe) + // ───────────────────────────────────────────────────── + + private startSsoCheckInterval(): void { + if (this.opts.ssoCheckIntervalMs <= 0) return; + + this.ssoCheckIntervalId = setInterval(() => { + if (!this.destroyed) void this.checkKeycloakSsoSession(); + }, this.opts.ssoCheckIntervalMs); + } + + private stopSsoCheckInterval(): void { + if (this.ssoCheckIntervalId !== null) { + clearInterval(this.ssoCheckIntervalId); + this.ssoCheckIntervalId = null; + } + } + + /** + * Comprueba silenciosamente si la sesión SSO de Keycloak sigue activa. + * Si el check falla (sesión terminada remotamente) → logout. + */ + private async checkKeycloakSsoSession(): Promise { + const kc = this.opts.getKeycloakInstance(); + if (!kc?.authenticated) return; // Solo aplica al flow SSO con Keycloak JS + + try { + // updateToken(0) fuerza a Keycloak JS a intentar refrescar via SSO + // Si la sesión SSO de Keycloak ha sido terminada, lanza un error + await kc.updateToken(0); + } catch { + console.warn('[SessionManager] Keycloak SSO session ended remotely'); + const detail: SessionExpiredDetail = { reason: 'keycloak_session_ended' }; + window.dispatchEvent(new CustomEvent(SESSION_EXPIRED_EVENT, { detail })); + this.opts.onSessionExpired('keycloak_session_ended'); + } + } + + // ───────────────────────────────────────────────────── + // Token refresh execution + // ───────────────────────────────────────────────────── + + private async doRefresh(reason: string): Promise { + if (this.destroyed || this.isRefreshing) return; + + // No refrescar automáticamente si el usuario está idle + // (excepto si es un refresh forzado por "extender sesión") + if (reason === 'scheduled' && this.isIdle()) { + console.info('[SessionManager] Omitiendo refresh — usuario inactivo'); + return; + } + + this.isRefreshing = true; + + try { + const kc = this.opts.getKeycloakInstance(); + let newToken: string | null = null; + + if (kc?.authenticated) { + // ── Keycloak JS flow ────────────────────────────────────────── + // updateToken intenta un silent refresh via iframe con la cookie + // de sesión SSO de Keycloak. + // Si el SSO session ha expirado, esto lanzará un error. + const minValidity = this.opts.refreshBeforeExpirySeconds + 10; + await kc.updateToken(minValidity); + newToken = kc.token ?? null; + } else { + // ── Password login flow ─────────────────────────────────────── + // Usar el endpoint server-side de SvelteKit que lee el refresh_token + // desde la cookie HttpOnly (el JS nunca ve el refresh_token). + newToken = await this.silentRefreshViaCookie(); + } + + if (newToken) { + this.currentToken = newToken; + this.cancelRefreshTimer(); + this.scheduleRefresh(newToken); + this.opts.onTokenRefreshed(newToken); + window.dispatchEvent( + new CustomEvent(SESSION_TOKEN_REFRESHED_EVENT, { detail: { token: newToken } }) + ); + } else { + this.handleRefreshFailure(); + } + } catch (err) { + console.error('[SessionManager] Error durante refresh:', err); + this.handleRefreshFailure(); + } finally { + this.isRefreshing = false; + } + } + + private handleRefreshFailure(): void { + console.warn('[SessionManager] Refresh fallido — la sesión SSO probablemente expiró'); + const detail: SessionExpiredDetail = { reason: 'refresh_failed' }; + window.dispatchEvent(new CustomEvent(SESSION_EXPIRED_EVENT, { detail })); + this.opts.onSessionExpired('refresh_failed'); + } + + /** + * Llama al endpoint server-side de SvelteKit para realizar el refresh + * usando la cookie HttpOnly del refresh_token. + * + * El servidor lee la cookie, llama a Keycloak, obtiene los nuevos tokens, + * actualiza las cookies HttpOnly y devuelve el nuevo access_token al cliente. + * El refresh_token NUNCA toca el código JavaScript del cliente. + */ + private async silentRefreshViaCookie(): Promise { + try { + const resp = await fetch('/api-sveltekit/auth/silent-refresh', { + method: 'POST', + credentials: 'include', // Envía todas las cookies HttpOnly + headers: { 'Content-Type': 'application/json' } + }); + + if (!resp.ok) return null; + + const data = await resp.json(); + return (data as { access_token?: string }).access_token ?? null; + } catch (err) { + console.error('[SessionManager] Error en silentRefreshViaCookie:', err); + return null; + } + } +} + +// ───────────────────────────────────────────────────────── +// Singleton helpers +// ───────────────────────────────────────────────────────── + +let _instance: SessionManager | null = null; + +/** Obtiene la instancia singleton del SessionManager */ +export function getSessionManager(): SessionManager | null { + return _instance; +} + +/** + * Crea (o recrea) el SessionManager singleton. + * Destruye la instancia anterior si existe. + */ +export function createSessionManager(options?: SessionManagerOptions): SessionManager { + if (_instance) { + _instance.destroy(); + } + _instance = new SessionManager(options); + return _instance; +} + +/** Destruye el SessionManager singleton y limpia todos los recursos */ +export function destroySessionManager(): void { + if (_instance) { + _instance.destroy(); + _instance = null; + } +} diff --git a/frontend/src/lib/stores/company.svelte.ts b/frontend/src/lib/stores/company.svelte.ts new file mode 100644 index 0000000..4b8794a --- /dev/null +++ b/frontend/src/lib/stores/company.svelte.ts @@ -0,0 +1,257 @@ +/** + * Store para manejar la compañía activa del usuario + * Permite cambiar entre las compañías que pertenecen al tenant + */ + +import { browser } from '$app/environment'; + +interface Company { + id: number; + name: string; + rfc?: string; + logo?: string; + tenant_id: number; +} + +class CompanyStore { + private _activeCompany = $state(null); + private _companies = $state([]); + private _loading = $state(false); + private _currentTenantId = $state(null); + + get activeCompany() { + return this._activeCompany; + } + + get companies() { + return this._companies; + } + + get loading() { + return this._loading; + } + + /** + * Carga las compañías del tenant del usuario desde el backend + * @param preloadedCompanies - Compañías pre-cargadas desde el servidor (SSR) + */ + async loadCompanies(preloadedCompanies?: Company[], activeCompanyId?: number) { + // Si tenemos compañías pre-cargadas, usarlas directamente + if (preloadedCompanies && preloadedCompanies.length > 0) { + // Detectar si el tenant ha cambiado + const newTenantId = preloadedCompanies[0].tenant_id; + + // Si el tenant cambió, limpiar el store primero + if (this._currentTenantId !== null && this._currentTenantId !== newTenantId) { + this.clear(); + } + + this._currentTenantId = newTenantId; + this._companies = preloadedCompanies; + + // Si hay compañías y no hay una activa, seleccionar la correcta. + // IMPORTANTE: `setActiveCompany` es async (dispara syncCompanyPermissions); + // hay que `await` para que el caller (initialize → markPermissionsHydrated) + // no marque la hidratación antes de que los permisos del backend lleguen. + if (this._companies.length > 0 && !this._activeCompany) { + // Prioridad 1: ID pasado por parámetro (desde SSR/Cookie) + if (activeCompanyId) { + const company = this._companies.find(c => c.id === activeCompanyId); + if (company) { + await this.setActiveCompany(company, true); + return; + } + } + + // Prioridad 2: Intentar restaurar de localStorage (Browser only) + if (typeof window !== 'undefined') { + const savedId = localStorage.getItem('activeCompanyId'); + if (savedId) { + const company = this._companies.find(c => c.id === parseInt(savedId)); + if (company) { + await this.setActiveCompany(company, true); // silent=true para inicialización + return; + } + } + } + // Prioridad 3: Si no hay nada, seleccionar la primera + await this.setActiveCompany(this._companies[0], true); // silent=true para inicialización + } + return; + } + + // Si no hay datos pre-cargados, hacer fetch (fallback) + // Solo en el navegador, nunca durante SSR + if (!browser) { + return; + } + + this._loading = true; + try { + // Importamos dinámicamente para evitar dependencias circulares si las hubiera + const { api } = await import('$lib/api'); + const response = await api.get('/v1/auth/my-companies'); + + if (response.data) { + const newCompanies = response.data; + + // Detectar si el tenant ha cambiado + if (newCompanies.length > 0) { + const newTenantId = newCompanies[0].tenant_id; + + // Si el tenant cambió, limpiar el store primero + if (this._currentTenantId !== null && this._currentTenantId !== newTenantId) { + this.clear(); + } + + this._currentTenantId = newTenantId; + } + + this._companies = newCompanies; + + // Si hay compañías y no hay una activa, seleccionar la primera. + // `await` necesario para esperar a syncCompanyPermissions antes de + // que la inicialización del layout marque permissionsHydrated. + if (this._companies.length > 0 && !this._activeCompany) { + await this.setActiveCompany(this._companies[0], true); // silent=true para inicialización + } + } else { + console.error('Error loading companies:', response.error); + if (response.status === 402) { + const { toast } = await import('svelte-sonner'); + toast.error('Licencia inactiva', { + duration: 8000, + description: response.error || 'Tu licencia no está activa para este tenant. Contacta al administrador.' + }); + this.clear(); + } else if (response.status === 401) { + this.clear(); + } + } + } catch (error) { + console.error('Error loading companies:', error); + } finally { + this._loading = false; + } + } + + /** + * Establece la compañía activa + * @param company - La compañía a establecer como activa + * @param silent - Si es true, no dispara el evento companyChanged (para inicialización) + */ + async setActiveCompany(company: Company, silent: boolean = false) { + const previousCompanyId = this._activeCompany?.id; + this._activeCompany = company; + + // Guardar en localStorage para persistencia + if (typeof window !== 'undefined') { + localStorage.setItem('activeCompanyId', company.id.toString()); + } + + // Guardar en cookie para acceso desde el servidor (SSR) + // Usar el endpoint del servidor para garantizar que la cookie esté disponible en SSR + if (browser) { + try { + await fetch('/api-sveltekit/company/set-active', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ companyId: company.id }), + credentials: 'include' + }); + } catch (error) { + console.error('Error setting active company cookie:', error); + } + try { + const { syncCompanyPermissions } = await import('$lib/auth'); + await syncCompanyPermissions(company.id); + } catch (e) { + console.warn('syncCompanyPermissions:', e); + } + } + + // Despachar evento personalizado solo si: + // 1. No es silent (no es inicialización) + // 2. Y realmente cambió la compañía (el ID es diferente) + if (!silent && typeof window !== 'undefined' && previousCompanyId !== company.id) { + window.dispatchEvent(new CustomEvent('companyChanged', { + detail: { companyId: company.id } + })); + } + } + + /** + * Actualiza los datos de une empresa en el store localmente + * Útil para reflejar cambios inmediatos (ej: cambio de logo) sin recargar + */ + updateCompany(id: number, data: Partial) { + // 1. Actualizar en la lista + const index = this._companies.findIndex(c => c.id === id); + if (index !== -1) { + this._companies[index] = { ...this._companies[index], ...data }; + + // 2. Si es la activa, actualizar también + if (this._activeCompany?.id === id) { + this._activeCompany = { ...this._activeCompany, ...data }; + // Actualizar persistencia si es necesario + if (typeof window !== 'undefined') { + // Disparar evento para notificar cambios a componentes que no usan el store reactivo directo (si los hay) + window.dispatchEvent(new CustomEvent('companyChanged', { + detail: { companyId: id } + })); + } + } + } + } + + /** + * Restaura la compañía activa desde localStorage + */ + restoreActiveCompany() { + if (typeof window !== 'undefined') { + const savedId = localStorage.getItem('activeCompanyId'); + if (savedId && this._companies.length > 0) { + const company = this._companies.find(c => c.id === parseInt(savedId)); + if (company) { + this._activeCompany = company; + } + } + } + } + + /** + * Limpia el store (útil al cambiar de tenant o cerrar sesión) + */ + clear() { + this._activeCompany = null; + this._companies = []; + this._loading = false; + this._currentTenantId = null; + + // Limpiar localStorage + if (typeof window !== 'undefined') { + localStorage.removeItem('activeCompanyId'); + } + + // Limpiar cookie + if (typeof document !== 'undefined') { + document.cookie = 'active_company_id=; path=/; max-age=0'; + } + } + + /** + * Inicializa el store cargando las compañías + * @param preloadedCompanies - Compañías pre-cargadas desde el servidor (SSR) + */ + async initialize(preloadedCompanies?: Company[], activeCompanyId?: number) { + await this.loadCompanies(preloadedCompanies, activeCompanyId); + // Si no hay compañías pre-cargadas, intentar restaurar de localStorage + if (!preloadedCompanies) { + this.restoreActiveCompany(); + } + } +} + +export const companyStore = new CompanyStore(); diff --git a/frontend/src/lib/stores/focus-store.ts b/frontend/src/lib/stores/focus-store.ts new file mode 100644 index 0000000..2402fa7 --- /dev/null +++ b/frontend/src/lib/stores/focus-store.ts @@ -0,0 +1,24 @@ +import { writable } from 'svelte/store'; + +export type FocusStrategy = 'first-input' | 'trigger'; +export type InteractionMode = 'mouse' | 'keyboard'; + +interface FocusRequest { + strategy: FocusStrategy; + description?: string; + timestamp: number; +} + +function createFocusStore() { + const { subscribe, set, update } = writable(null); + + return { + subscribe, + request: (strategy: FocusStrategy, description?: string) => { + set({ strategy, description, timestamp: Date.now() }); + } + }; +} + +export const focusStore = createFocusStore(); +export const interactionMode = writable('mouse'); diff --git a/frontend/src/lib/stores/help.svelte.ts b/frontend/src/lib/stores/help.svelte.ts new file mode 100644 index 0000000..19fe53e --- /dev/null +++ b/frontend/src/lib/stores/help.svelte.ts @@ -0,0 +1,9 @@ +// Store to control the Help Drawer state globally +// Using Svelte 5 runes + +export const helpStore = $state({ + isOpen: false, + open() { this.isOpen = true; }, + close() { this.isOpen = false; }, + toggle() { this.isOpen = !this.isOpen; } +}); diff --git a/frontend/src/lib/stores/shortcut-store.ts b/frontend/src/lib/stores/shortcut-store.ts new file mode 100644 index 0000000..b8cc8ae --- /dev/null +++ b/frontend/src/lib/stores/shortcut-store.ts @@ -0,0 +1,76 @@ +import { writable, derived } from 'svelte/store'; + +export interface ShortcutDef { + key: string; // e.g., 'Ctrl+S' + description: string; + action: () => void; + group?: string; + /** + * When true, KeyboardManager will not run the default post-action focus + * (focusMainContentPrimary) for Alt+ shortcuts. Use when the action sets focus explicitly. + */ + skipDefaultFocusAfter?: boolean; +} + +interface ShortcutState { + contexts: Record; +} + +function createShortcutStore() { + const { subscribe, set, update } = writable({ + contexts: {} + }); + + return { + subscribe, + /** + * Register local shortcuts for the current view. + * Call this on mount (or $effect). + */ + register: (context: string, shortcuts: ShortcutDef[]) => { + update(state => ({ + ...state, + contexts: { + ...state.contexts, + [context]: shortcuts + } + })); + }, + /** + * Clear shortcuts (on unmount) + */ + clear: (contextToClear: string) => { + update(state => { + const newContexts = { ...state.contexts }; + delete newContexts[contextToClear]; + return { ...state, contexts: newContexts }; + }); + } + }; +} + +export const shortcutStore = createShortcutStore(); + +/** + * Flattened list of all active shortcuts across ALL contexts. + * Last registered context takes priority in case of key conflicts (or we can decide otherwise). + */ +export const activeShortcutsList = derived(shortcutStore, ($store) => { + const list: (ShortcutDef & { context: string })[] = []; + for (const [context, shortcuts] of Object.entries($store.contexts)) { + (shortcuts as ShortcutDef[]).forEach((s: ShortcutDef) => list.push({ ...s, context })); + } + return list; +}); + +// For backward compatibility +export const activeShortcuts = derived(shortcutStore, ($state) => { + const list: (ShortcutDef & { context: string })[] = []; + for (const [context, shortcuts] of Object.entries($state.contexts)) { + (shortcuts as ShortcutDef[]).forEach((s: ShortcutDef) => list.push({ ...s, context })); + } + return { + context: Object.keys($state.contexts).join(', ') || 'None', + shortcuts: list + }; +}); diff --git a/frontend/src/lib/stores/system.svelte.ts b/frontend/src/lib/stores/system.svelte.ts new file mode 100644 index 0000000..8cbac0a --- /dev/null +++ b/frontend/src/lib/stores/system.svelte.ts @@ -0,0 +1,34 @@ +/** + * SystemStore — plantilla base. + * En Anexo76 manejaba sistemas SCAF/SCAII. En la plantilla es un stub. + * Implementa tu propia lógica de sistemas/módulos si la necesitas. + */ + +export type SystemType = string; + +class SystemStore { + _activeSystem = $state(null); + _allowedSystems = $state([]); + + get activeSystem() { return this._activeSystem; } + get allowedSystems() { return this._allowedSystems; } + get canSwitch() { return this._allowedSystems.length > 1; } + get activeLabel() { return null; } + + initialize(allowedSystems: SystemType[], cookieValue: string | null) { + this._allowedSystems = allowedSystems; + this._activeSystem = cookieValue ?? allowedSystems[0] ?? null; + } + + async setActiveSystem(system: SystemType): Promise { + this._activeSystem = system; + return true; + } + + clear() { + this._activeSystem = null; + this._allowedSystems = []; + } +} + +export const systemStore = new SystemStore(); diff --git a/frontend/src/lib/stores/ui.svelte.ts b/frontend/src/lib/stores/ui.svelte.ts new file mode 100644 index 0000000..36bd0e0 --- /dev/null +++ b/frontend/src/lib/stores/ui.svelte.ts @@ -0,0 +1,17 @@ + +/** + * UI Store for managing global UI states + */ +class UIStore { + private _isExchangeRateDialogOpen = $state(false); + + get isExchangeRateDialogOpen() { + return this._isExchangeRateDialogOpen; + } + + set isExchangeRateDialogOpen(value: boolean) { + this._isExchangeRateDialogOpen = value; + } +} + +export const uiStore = new UIStore(); diff --git a/frontend/src/lib/stores/workspace-apps.svelte.ts b/frontend/src/lib/stores/workspace-apps.svelte.ts new file mode 100644 index 0000000..3d680ea --- /dev/null +++ b/frontend/src/lib/stores/workspace-apps.svelte.ts @@ -0,0 +1,42 @@ +/** App del Workspace normalizada para el launcher. */ +export type WorkspaceApp = { + id: string; + name: string; + /** Identificador legible (subtítulo en la tarjeta del launcher). */ + slug: string | null; + /** URL de entrada de la app (login_url); para apps del mismo origin trae `?active_system`. */ + url: string; + iconUrl: string | null; +}; + +/** + * Store de las apps del Workspace/Hub accesibles al usuario (launcher de productos). + * Es independiente de `systemStore` (SCAF/SCAII): aquí viven los productos del Workspace, + * allá el sistema interno de Mi Aplicación. + */ +class WorkspaceAppsStore { + _apps = $state([]); + _routing = $state(''); + + get apps() { + return this._apps; + } + get routing() { + return this._routing; + } + get hasApps() { + return this._apps.length > 0; + } + + initialize(apps: WorkspaceApp[], routing: string | null) { + this._apps = Array.isArray(apps) ? apps : []; + this._routing = routing ?? ''; + } + + clear() { + this._apps = []; + this._routing = ''; + } +} + +export const workspaceAppsStore = new WorkspaceAppsStore(); diff --git a/frontend/src/lib/utils.avatar.test.ts b/frontend/src/lib/utils.avatar.test.ts new file mode 100644 index 0000000..dbcd7ae --- /dev/null +++ b/frontend/src/lib/utils.avatar.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { resolveUserAvatarUrl } from './utils'; + +describe('resolveUserAvatarUrl', () => { + it('prioriza avatar de Workspace cuando es URL absoluta', () => { + const value = resolveUserAvatarUrl('https://hub.example.com/media/avatar.png', '/uploads/legacy.png'); + expect(value).toBe('https://hub.example.com/media/avatar.png'); + }); + + it('acepta avatar de Workspace relativo cuando no hay VITE_HUB_URL', () => { + const value = resolveUserAvatarUrl('/media/avatar.png', '/uploads/legacy.png'); + expect(value).toBe('/media/avatar.png'); + }); + + it('usa avatar legado cuando Workspace no existe', () => { + const value = resolveUserAvatarUrl(null, '/uploads/legacy.png'); + expect(value).toBe('http://localhost:8000/uploads/legacy.png'); + }); + + it('retorna vacio para fallback visual cuando no hay ninguna imagen', () => { + const value = resolveUserAvatarUrl(null, null); + expect(value).toBe(''); + }); +}); diff --git a/frontend/src/lib/utils.getBackendAssetUrl.test.ts b/frontend/src/lib/utils.getBackendAssetUrl.test.ts new file mode 100644 index 0000000..b399635 --- /dev/null +++ b/frontend/src/lib/utils.getBackendAssetUrl.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest' +import { getBackendAssetUrl } from './utils' + +describe('getBackendAssetUrl', () => { + + it('devuelve string vacío si path es null', () => { + expect(getBackendAssetUrl(null)).toBe('') + }) + + it('devuelve string vacío si path es undefined', () => { + expect(getBackendAssetUrl(undefined)).toBe('') + }) + + it('devuelve la URL tal cual si ya es completa', () => { + expect(getBackendAssetUrl('http://ejemplo.com/archivo.png')).toBe('http://ejemplo.com/archivo.png') + }) + + it('reescribe host interno de Docker a host publico', () => { + expect(getBackendAssetUrl('http://hub-backend:8000/api/static/avatars/file.png')).toBe('http://localhost:8000/api/static/avatars/file.png') + }) + + it('reescribe URL sin protocolo con host interno', () => { + expect(getBackendAssetUrl('hub-backend:8000/api/static/avatars/file.png')).toBe('http://localhost:8000/api/static/avatars/file.png') + }) + + it('evita duplicar /api en la URL', () => { + expect(getBackendAssetUrl('/api/v1/items')).toBe('http://localhost:8000/api/v1/items') + }) + + it('construye URL completa para ruta relativa (VITE_API_URL por defecto host:8000)', () => { + // getBackendAssetUrl solo acepta path; la base sale de VITE o fallback http://localhost:8000 + expect(getBackendAssetUrl('/uploads/file.png')).toBe('http://localhost:8000/uploads/file.png') + }) + +}) \ No newline at end of file diff --git a/frontend/src/lib/utils.getFileHelpers.test.ts b/frontend/src/lib/utils.getFileHelpers.test.ts new file mode 100644 index 0000000..dc186d3 --- /dev/null +++ b/frontend/src/lib/utils.getFileHelpers.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest' +import { getFileNameFromPath, getFileDisplayName } from './utils' + +describe('getFileNameFromPath', () => { + + it('devuelve string vacío si recibe null', () => { + expect(getFileNameFromPath(null)).toBe('') + }) + + it('devuelve string vacío si recibe undefined', () => { + expect(getFileNameFromPath(undefined)).toBe('') + }) + + it('devuelve solo el nombre del archivo', () => { + expect(getFileNameFromPath('/uploads/avatars/file.png')).toBe('file.png') + }) + + it('devuelve nombre sin query string', () => { + expect(getFileNameFromPath('/uploads/file.png?token=123')).toBe('file.png') + }) + +}) + +describe('getFileDisplayName', () => { + + it('devuelve label por defecto si filePath es null', () => { + expect(getFileDisplayName(null)).toBe('Seleccionar archivo') + }) + + it('devuelve label por defecto si filePath es undefined', () => { + expect(getFileDisplayName(undefined)).toBe('Seleccionar archivo') + }) + + it('devuelve label personalizado si se pasa defaultLabel', () => { + expect(getFileDisplayName(null, undefined, 'Subir archivo')).toBe('Subir archivo') + }) + + it('devuelve solo el nombre del archivo sin fileType', () => { + expect(getFileDisplayName('/uploads/file.png')).toBe('file.png') + }) + + it('devuelve nombre con tipo en mayúsculas', () => { + expect(getFileDisplayName('/uploads/file.png', 'pdf')).toBe('file.png (PDF)') + }) + +}) \ No newline at end of file diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 0000000..3a51cbf --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,202 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +}; + +function isInternalDockerHost(hostname: string): boolean { + const host = hostname.toLowerCase(); + if (!host) return false; + if (host === 'localhost' || host === '127.0.0.1') return false; + if (host === 'backend' || host === 'hub-backend' || host === 'host.docker.internal') return true; + // Nombres de servicio Docker suelen no contener punto. + return !host.includes('.'); +} + +function getApiPublicBaseOrigin(): string { + const raw = (import.meta.env.VITE_API_URL || '').trim(); + if (raw) { + try { + const parsed = new URL(raw); + if (isInternalDockerHost(parsed.hostname)) { + if (typeof window !== 'undefined' && window.location?.origin) { + return window.location.origin; + } + return 'http://localhost:8000'; + } + return `${parsed.protocol}//${parsed.host}`; + } catch { + return raw.replace(/\/+$/, ''); + } + } + + if (typeof window !== 'undefined' && window.location?.origin) { + return window.location.origin; + } + + return 'http://localhost:8000'; +} + +function parseAbsoluteLikeUrl(value: string): URL | null { + const trimmed = value.trim(); + if (!trimmed) return null; + + try { + const parsed = new URL(trimmed); + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + return parsed; + } + } catch { + } + + // Soporta formato host:puerto/ruta sin protocolo. + if (/^[a-z0-9.-]+:\d+\//i.test(trimmed)) { + try { + return new URL(`http://${trimmed}`); + } catch { + return null; + } + } + + return null; +} + +function rewriteInternalHostToPublicUrl(value: string): string { + const parsed = parseAbsoluteLikeUrl(value); + if (!parsed) return value; + + if (isInternalDockerHost(parsed.hostname)) { + const publicOrigin = getApiPublicBaseOrigin(); + return `${publicOrigin}${parsed.pathname}${parsed.search}${parsed.hash}`; + } + + return value; +} + +/** + * Convierte una ruta relativa del backend en una URL completa + * @param path Ruta relativa (ej: "/uploads/avatars/file.png") o absoluta API (ej: "/api/v1/...") + * @returns URL completa del backend + */ +export function getBackendAssetUrl(path: string | null | undefined): string { + if (!path) return ''; + + // Si ya es una URL completa, retornarla tal cual + if (path.startsWith('http://') || path.startsWith('https://')) { + return rewriteInternalHostToPublicUrl(path); + } + + if (/^[a-z0-9.-]+:\d+\//i.test(path)) { + return rewriteInternalHostToPublicUrl(path); + } + + const normalized = path.startsWith('/') ? path : `/${path}`; + + // Obtener la base URL del API y limpiar el / final si existe + let baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000'; + baseUrl = baseUrl.replace(/\/+$/, ''); + + // VITE_API_URL suele ser .../api; las rutas del backend a veces vienen como /api/v1/... + // Evitar http://host/api/api/v1/... + if (normalized.startsWith('/api/') && baseUrl.endsWith('/api')) { + const origin = baseUrl.slice(0, -'/api'.length); + return `${origin}${normalized}`; + } + + const cleanPath = normalized.startsWith('/') ? normalized.slice(1) : normalized; + return `${baseUrl}/${cleanPath}`; +} + +export function isSafeHttpUrl(value: string | null | undefined): boolean { + if (!value) return false; + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +} + +function getHubAssetBaseUrl(): string { + const hubBase = (import.meta.env.VITE_HUB_URL || '').trim(); + if (!hubBase) return ''; + return hubBase.replace(/\/+$/, ''); +} + +function normalizeWorkspaceAvatarUrl(value: string | null | undefined): string { + if (!value || typeof value !== 'string') return ''; + const trimmed = value.trim(); + if (!trimmed) return ''; + + if (isSafeHttpUrl(trimmed)) { + return rewriteInternalHostToPublicUrl(trimmed); + } + + if (/^[a-z0-9.-]+:\d+\//i.test(trimmed)) { + return rewriteInternalHostToPublicUrl(trimmed); + } + + // Workspace puede devolver rutas relativas (ej: /media/avatar.png). + if (trimmed.startsWith('/')) { + const hubBase = getHubAssetBaseUrl(); + if (hubBase) { + return `${hubBase}${trimmed}`; + } + // Si no hay HUB_URL pública, intentar resolver en el mismo origen. + return trimmed; + } + + return ''; +} + +/** + * Prioridad de avatar de usuario: + * 1) workspaceAvatarUrl (http/https válido) + * 2) avatar local legado + * 3) fallback visual del componente Avatar + */ +export function resolveUserAvatarUrl( + workspaceAvatarUrl: string | null | undefined, + legacyAvatarUrl: string | null | undefined +): string { + const normalizedWorkspaceAvatar = normalizeWorkspaceAvatarUrl(workspaceAvatarUrl); + if (normalizedWorkspaceAvatar) { + return normalizedWorkspaceAvatar; + } + return getBackendAssetUrl(legacyAvatarUrl); +} + +/** + * Obtiene únicamente el nombre del archivo a partir de una ruta/URL. + */ +export function getFileNameFromPath(filePath: string | null | undefined): string { + if (!filePath) return ''; + + const withoutQuery = filePath.split('?')[0].split('#')[0]; + const normalizedPath = withoutQuery.replace(/\\/g, '/'); + return normalizedPath.split('/').filter(Boolean).pop() || withoutQuery; +} + +/** + * Formatea el texto de visualización para archivos evitando mostrar rutas completas. + */ +export function getFileDisplayName( + filePath: string | null | undefined, + fileType?: string, + defaultLabel = 'Seleccionar archivo' +): string { + if (!filePath) return defaultLabel; + + const fileName = getFileNameFromPath(filePath); + if (!fileName) return defaultLabel; + return fileType ? `${fileName} (${fileType.toUpperCase()})` : fileName; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WithoutChild = T extends { child?: any } ? Omit : T; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WithoutChildren = T extends { children?: any } ? Omit : T; +export type WithoutChildrenOrChild = WithoutChildren>; +export type WithElementRef = T & { ref?: U | null }; + diff --git a/frontend/src/lib/utils/date.ts b/frontend/src/lib/utils/date.ts new file mode 100644 index 0000000..2a5163b --- /dev/null +++ b/frontend/src/lib/utils/date.ts @@ -0,0 +1,57 @@ +/** + * Utility for date formatting and conversion. + * Canonical API format: DD/MM/YYYY + * Native input[type="date"] format: YYYY-MM-DD + */ + +/** + * Converts DD/MM/YYYY or YYYYMMDD to YYYY-MM-DD for native date input. + */ +export function toInputDate(dateStr: string | number | null | undefined): string { + if (!dateStr) return ''; + + const str = String(dateStr); + + if (/^\d{8}$/.test(str)) { + const y = str.substring(0, 4); + const m = str.substring(4, 6); + const d = str.substring(6, 8); + return `${y}-${m}-${d}`; + } + + if (/^\d{2}\/\d{2}\/\d{4}$/.test(str)) { + const [d, m, y] = str.split('/'); + return `${y}-${m}-${d}`; + } + + if (/^\d{4}-\d{2}-\d{2}$/.test(str)) { + return str; + } + + return ''; +} + +/** + * Converts YYYY-MM-DD or YYYYMMDD to DD/MM/YYYY for API. + */ +export function toDbDate(dateStr: string | null | undefined): string | null { + if (!dateStr) return null; + + if (/^\d{2}\/\d{2}\/\d{4}$/.test(dateStr)) { + return dateStr; + } + + if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) { + const [y, m, d] = dateStr.split('-'); + return `${d}/${m}/${y}`; + } + + if (/^\d{8}$/.test(dateStr)) { + const y = dateStr.substring(0, 4); + const m = dateStr.substring(4, 6); + const d = dateStr.substring(6, 8); + return `${d}/${m}/${y}`; + } + + return null; +} diff --git a/frontend/src/lib/utils/error-handler.ts b/frontend/src/lib/utils/error-handler.ts new file mode 100644 index 0000000..9f3db13 --- /dev/null +++ b/frontend/src/lib/utils/error-handler.ts @@ -0,0 +1,84 @@ +/** + * Utilidades para manejar errores de API en el cliente + */ +import { toast } from 'svelte-sonner'; + +export interface ApiError { + detail: string; + status: number; + isForbidden?: boolean; +} + +/** + * Maneja errores de API mostrando el toast apropiado + * @param error - El error a manejar (puede ser un objeto ApiError o un string) + * @returns true si se manejó un error, false si no había error + */ +export function handleApiError(error?: ApiError | string | null): boolean { + if (!error) return false; + + // Si es un string, convertirlo a objeto + if (typeof error === 'string') { + // Detectar si es un error 403 + if (error.includes('403') || error.toLowerCase().includes('forbidden')) { + toast.error(error, { + duration: 5000, + description: 'No tienes permisos para realizar esta acción' + }); + return true; + } + + // Otros errores en formato string + toast.error(error, { + duration: 4000 + }); + return true; + } + + // Es un objeto ApiError + if (error.isForbidden || error.status === 403) { + // Mostrar el mensaje específico del backend si está disponible + const message = error.detail || 'No tienes permisos para realizar esta acción'; + toast.error(message, { + duration: 5000, + description: error.detail ? 'Contacta a tu administrador si crees que esto es un error' : undefined + }); + return true; + } + + if (error.status === 401) { + toast.error('Sesión expirada', { + duration: 3000, + description: 'Por favor, inicia sesión nuevamente' + }); + return true; + } + + // Otros errores + toast.error(error.detail || 'Error en la operación', { + duration: 4000 + }); + return true; +} + +/** + * Hook para usar en componentes Svelte con $effect + * Muestra automáticamente un toast cuando hay un error + * + * Ejemplo de uso en +page.svelte: + * ```svelte + * + * ``` + */ +export function useErrorHandler(error?: ApiError | null) { + if (error) { + handleApiError(error); + } +} diff --git a/frontend/src/lib/utils/permissions.ts b/frontend/src/lib/utils/permissions.ts new file mode 100644 index 0000000..021f539 --- /dev/null +++ b/frontend/src/lib/utils/permissions.ts @@ -0,0 +1,71 @@ +/** + * Helper para verificar permisos del usuario + * Basado en el sistema de permisos RBAC del backend + */ + +import { get } from 'svelte/store'; +import { page } from '$app/stores'; + +export interface UserPermission { + module: string; + action: string; +} + +/** + * Verifica si el usuario tiene un permiso específico + * @param module - El módulo (ej: 'invoices', 'pedimentos') + * @param action - La acción (ej: 'create', 'update', 'delete', 'read') + * @returns true si el usuario tiene el permiso, false si no + */ +export function hasPermission(module: string, action: string): boolean { + // TODO: Implementar verificación real contra permisos del usuario + // Por ahora retorna true para permitir desarrollo + // En producción esto debe: + // 1. Obtener los permisos del usuario desde el contexto/store + // 2. Verificar si existe un permiso con module y action + // 3. Retornar true/false basado en la verificación + + console.warn('hasPermission() no está implementado - retornando true por defecto'); + return true; +} + +/** + * Verifica si el usuario tiene alguno de varios permisos + * @param permissions - Array de permisos a verificar + * @returns true si el usuario tiene al menos uno de los permisos + */ +export function hasAnyPermission(permissions: UserPermission[]): boolean { + return permissions.some(p => hasPermission(p.module, p.action)); +} + +/** + * Verifica si el usuario tiene todos los permisos especificados + * @param permissions - Array de permisos a verificar + * @returns true si el usuario tiene todos los permisos + */ +export function hasAllPermissions(permissions: UserPermission[]): boolean { + return permissions.every(p => hasPermission(p.module, p.action)); +} + +/** + * Guard para proteger rutas basado en permisos + * Puede ser usado en +page.server.ts o +layout.server.ts + * @param module - El módulo requerido + * @param action - La acción requerida + * @returns objeto con allowed (boolean) y redirect (string opcional) + */ +export function requirePermission(module: string, action: string): { + allowed: boolean; + redirect?: string; +} { + const allowed = hasPermission(module, action); + + if (!allowed) { + return { + allowed: false, + redirect: '/dashboard?error=forbidden' + }; + } + + return { allowed: true }; +} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte new file mode 100644 index 0000000..169654c --- /dev/null +++ b/frontend/src/routes/+layout.svelte @@ -0,0 +1,37 @@ + + + + + + +{@render children?.()} + + + + + + console.log('Global Search Focused')} +/> diff --git a/frontend/src/routes/+page.server.ts b/frontend/src/routes/+page.server.ts new file mode 100644 index 0000000..ba19381 --- /dev/null +++ b/frontend/src/routes/+page.server.ts @@ -0,0 +1,40 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; +import { getAuthTokens, authenticatedFetch, clearAuthTokens } from '$lib/server/api'; + +export const load: PageServerLoad = async ({ cookies, fetch }) => { + const { accessToken } = getAuthTokens(cookies); + + // Si hay token, validar que sea válido antes de redirigir + if (accessToken) { + try { + // Verificar si el token es válido usando authenticatedFetch + const response = await authenticatedFetch( + 'v1/auth/me', + {}, + cookies, + fetch + ); + + // Solo redirigir al dashboard si el token es válido + if (response.ok) { + throw redirect(303, '/dashboard'); + } else { + // Token inválido, limpiar cookies y mostrar la página pública + clearAuthTokens(cookies); + } + } catch (error) { + // Si es un redirect, re-lanzarlo + if (error && typeof error === 'object' && 'status' in error && 'location' in error) { + throw error; + } + // Para otros errores, limpiar cookies y continuar + clearAuthTokens(cookies); + } + } + + // Si no está autenticado, mostrar la página principal pública + return { + isAuthenticated: false + }; +}; diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte new file mode 100644 index 0000000..4e8e7dc --- /dev/null +++ b/frontend/src/routes/+page.svelte @@ -0,0 +1,54 @@ + + + + Mi Aplicación + + +
    + + +
    + Mi Aplicación + + + Iniciar sesión + +
    + + +
    +
    + Plantilla base · Workspace SaaS +
    + +

    + Bienvenido a tu nueva aplicación +

    + +

    + Esta es la landing page de la plantilla. Reemplaza este contenido con la + propuesta de valor de tu producto. +

    + + +
    + + +
    + Mi Aplicación · Construido sobre la plantilla Workspace +
    + +
    diff --git a/frontend/src/routes/api-sveltekit/auth/silent-refresh/+server.ts b/frontend/src/routes/api-sveltekit/auth/silent-refresh/+server.ts new file mode 100644 index 0000000..3d9736d --- /dev/null +++ b/frontend/src/routes/api-sveltekit/auth/silent-refresh/+server.ts @@ -0,0 +1,62 @@ +/** + * Endpoint server-side para el refresh silencioso del access token. + * + * Flujo de seguridad: + * 1. El cliente llama a POST /api-sveltekit/auth/silent-refresh con credentials:'include' + * (las cookies HttpOnly se envían automáticamente, sin que JS las lea). + * 2. Este servidor lee el refresh_token de la cookie HttpOnly. + * 3. Llama al backend FastAPI /v1/auth/refresh con el refresh_token. + * 4. Si es exitoso, actualiza las cookies HttpOnly con los nuevos tokens. + * 5. Retorna solo el access_token al cliente (el refresh_token permanece en HttpOnly). + * + * De este modo el refresh_token NUNCA toca el código JavaScript del cliente. + */ + +import { json } from '@sveltejs/kit'; +import type { RequestEvent } from '@sveltejs/kit'; +import { getServerApiUrl, setAuthTokens } from '$lib/server/api'; +import { clearAccessTokenCookies } from '$lib/server/access-token-cookie'; + +export const POST = async ({ cookies, fetch }: RequestEvent) => { + const refreshToken = cookies.get('refresh_token'); + + if (!refreshToken) { + return json({ error: 'No refresh token available' }, { status: 401 }); + } + + try { + const baseUrl = getServerApiUrl(); + + const response = await fetch(`${baseUrl}v1/auth/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: refreshToken }) + }); + + if (!response.ok) { + // El refresh token expiró o fue invalidado por Keycloak (sesión terminada). + // Limpiar las cookies para que el servidor redirigir al login en la siguiente carga. + cookies.delete('refresh_token', { path: '/' }); + clearAccessTokenCookies(cookies); + cookies.delete('active_company_id', { path: '/' }); + + const status = response.status === 401 ? 401 : 400; + return json({ error: 'Refresh token expired or invalid' }, { status }); + } + + const data = (await response.json()) as { + access_token: string; + refresh_token?: string; + expires_in?: number; + }; + + // Actualizar las cookies HttpOnly con los nuevos tokens + setAuthTokens(cookies, data.access_token, data.refresh_token); + + // Devolver solo el access_token al cliente + return json({ access_token: data.access_token }); + } catch (error) { + console.error('[silent-refresh] Error inesperado:', error); + return json({ error: 'Internal server error' }, { status: 500 }); + } +}; diff --git a/frontend/src/routes/api-sveltekit/auth/switch-tenant/+server.ts b/frontend/src/routes/api-sveltekit/auth/switch-tenant/+server.ts new file mode 100644 index 0000000..143fe04 --- /dev/null +++ b/frontend/src/routes/api-sveltekit/auth/switch-tenant/+server.ts @@ -0,0 +1,98 @@ +/** + * Endpoint server-side para cambiar de tenant sin exponer el refresh_token al cliente. + * + * Dos modos: + * - { tenant_id } → flujo SSO relay: solo actualiza cookie sso_tenant_id (override de tenant) + * - { tenant_slug } → flujo login clásico: re-emite tokens KC para el nuevo tenant + */ + +import { json } from '@sveltejs/kit'; +import { env } from '$env/dynamic/private'; +import type { RequestEvent } from '@sveltejs/kit'; +import { getServerApiUrl, getAuthTokens, setAuthTokens } from '$lib/server/api'; + +export const POST = async ({ request, cookies, fetch }: RequestEvent) => { + const body = await request.json(); + const { tenant_id, tenant_slug } = body as { tenant_id?: number; tenant_slug?: string }; + + if (!tenant_id && !tenant_slug) { + return json({ error: 'tenant_id or tenant_slug is required' }, { status: 400 }); + } + + const { accessToken, refreshToken } = getAuthTokens(cookies); + + if (!accessToken) { + return json({ error: 'Not authenticated' }, { status: 401 }); + } + + // Modo SSO relay: validar acceso vía Hub y actualizar cookie de override + if (tenant_id) { + try { + const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, ''); + const tenantsRes = await fetch(`${hubUrl}/api/v1/auth/my-tenants`, { + headers: { 'Authorization': `Bearer ${accessToken}` }, + }); + if (!tenantsRes.ok) { + return json({ error: 'Could not validate tenant access' }, { status: 403 }); + } + const tenants: { id: number }[] = await tenantsRes.json(); + const hasAccess = tenants.some((t) => t.id === tenant_id); + if (!hasAccess) { + return json({ error: 'Access denied to tenant' }, { status: 403 }); + } + const { isSecureContext } = await import('$lib/server/workspace-auth'); + const isProduction = isSecureContext(); + cookies.set('sso_tenant_id', String(tenant_id), { + path: '/', + httpOnly: true, + secure: isProduction, + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 7, + }); + cookies.set('sso_tenant_pub', String(tenant_id), { + path: '/', + httpOnly: false, + secure: isProduction, + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 7, + }); + cookies.delete('active_company_id', { path: '/' }); + return json({ ok: true }); + } catch (error) { + console.error('[switch-tenant] SSO mode error:', error); + return json({ error: 'Internal server error' }, { status: 500 }); + } + } + + // Modo login clásico: re-emitir tokens KC para el nuevo tenant + if (!refreshToken) { + return json({ error: 'Not authenticated' }, { status: 401 }); + } + + try { + const baseUrl = getServerApiUrl(); + const response = await fetch(`${baseUrl}v1/auth/switch-tenant`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${accessToken}`, + }, + body: JSON.stringify({ tenant_slug, refresh_token: refreshToken }), + }); + + if (!response.ok) { + const err = await response.json().catch(() => ({})); + return json({ error: err.detail || 'Switch failed' }, { status: response.status }); + } + + const data = await response.json(); + setAuthTokens(cookies, data.access_token, data.refresh_token); + cookies.delete('active_company_id', { path: '/' }); + cookies.delete('sso_tenant_id', { path: '/' }); + cookies.delete('sso_tenant_pub', { path: '/' }); + return json({ ok: true }); + } catch (error) { + console.error('[switch-tenant] Classic mode error:', error); + return json({ error: 'Internal server error' }, { status: 500 }); + } +}; diff --git a/frontend/src/routes/api-sveltekit/company/my-companies/+server.ts b/frontend/src/routes/api-sveltekit/company/my-companies/+server.ts new file mode 100644 index 0000000..24fe4f3 --- /dev/null +++ b/frontend/src/routes/api-sveltekit/company/my-companies/+server.ts @@ -0,0 +1,49 @@ +/** + * API route proxy para obtener las compañías del usuario + */ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getAccessTokenFromCookies } from '$lib/server/access-token-cookie'; + +export const GET: RequestHandler = async ({ cookies, fetch }) => { + const token = getAccessTokenFromCookies(cookies); + + if (!token) { + // Limpiar cualquier cookie de compañía si no hay autenticación + cookies.delete('active_company_id', { path: '/' }); + return json({ error: 'No authenticated' }, { status: 401 }); + } + + // Configurar la URL de la API + let apiUrl = process.env.INTERNAL_API_URL; + if (!apiUrl) { + apiUrl = process.env.VITE_API_URL; + // Reemplazar 'localhost' con 'backend' para llamadas desde el servidor (SSR) + apiUrl = apiUrl?.replace('localhost', 'backend').replace('127.0.0.1', 'backend'); + } + + // Normalizar la URL + const baseUrl = apiUrl?.endsWith('/') ? apiUrl : `${apiUrl}/`; + + try { + const response = await fetch(`${baseUrl}v1/auth/my-companies`, { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + // Si la autenticación falló, limpiar la cookie de compañía + if (response.status === 401) { + cookies.delete('active_company_id', { path: '/' }); + } + return json({ error: 'Failed to fetch companies' }, { status: response.status }); + } + const companies = await response.json(); + return json(companies); + } catch (error) { + console.error('Error fetching companies:', error); + return json({ error: 'Internal server error' }, { status: 500 }); + } +}; diff --git a/frontend/src/routes/api-sveltekit/company/set-active/+server.ts b/frontend/src/routes/api-sveltekit/company/set-active/+server.ts new file mode 100644 index 0000000..3820adf --- /dev/null +++ b/frontend/src/routes/api-sveltekit/company/set-active/+server.ts @@ -0,0 +1,29 @@ +/** + * API route para establecer la compañía activa en una cookie + */ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ cookies, request }) => { + try { + const { companyId } = await request.json(); + + if (!companyId || typeof companyId !== 'number') { + return json({ error: 'Invalid company ID' }, { status: 400 }); + } + + // Establecer la cookie desde el servidor + cookies.set('active_company_id', companyId.toString(), { + path: '/', + maxAge: 60 * 60 * 24 * 30, // 30 días + sameSite: 'lax', + httpOnly: false, // Permitir acceso desde JavaScript + secure: process.env.NODE_ENV === 'production' + }); + + return json({ success: true, companyId }); + } catch (error) { + console.error('Error setting active company:', error); + return json({ error: 'Internal server error' }, { status: 500 }); + } +}; diff --git a/frontend/src/routes/api-sveltekit/system/set-active/+server.ts b/frontend/src/routes/api-sveltekit/system/set-active/+server.ts new file mode 100644 index 0000000..e8ecb5b --- /dev/null +++ b/frontend/src/routes/api-sveltekit/system/set-active/+server.ts @@ -0,0 +1,46 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getAccessTokenFromCookies } from '$lib/server/access-token-cookie'; +import { + extractAllowedSystemsFromToken, + fetchAllowedSystems, + isValidSystem, + mergeTokenClaims, + setActiveSystemCookie +} from '$lib/server/system-gate'; + +export const POST: RequestHandler = async ({ cookies, request }) => { + try { + const { system } = await request.json(); + + if (!isValidSystem(system)) { + return json({ error: 'Sistema inválido' }, { status: 400 }); + } + + const accessToken = getAccessTokenFromCookies(cookies); + if (!accessToken) { + return json({ error: 'No autenticado' }, { status: 401 }); + } + + const tokenClaims = mergeTokenClaims(null, accessToken); + let allowedSystems = extractAllowedSystemsFromToken(tokenClaims); + + if (allowedSystems.length === 0) { + const rawCompanyId = cookies.get('active_company_id'); + const companyId = rawCompanyId ? Number.parseInt(rawCompanyId, 10) : NaN; + if (Number.isFinite(companyId)) { + allowedSystems = await fetchAllowedSystems(cookies, fetch, companyId); + } + } + + if (!allowedSystems.includes(system)) { + return json({ error: 'No tienes acceso a ese sistema' }, { status: 403 }); + } + + setActiveSystemCookie(cookies, system); + + return json({ success: true, system }); + } catch { + return json({ error: 'Internal server error' }, { status: 500 }); + } +}; diff --git a/frontend/src/routes/auth/callback/+page.server.ts b/frontend/src/routes/auth/callback/+page.server.ts new file mode 100644 index 0000000..42cade0 --- /dev/null +++ b/frontend/src/routes/auth/callback/+page.server.ts @@ -0,0 +1,132 @@ +import { redirect, isRedirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; +import { setAccessTokenCookies } from '$lib/server/access-token-cookie'; +import { + clearWorkspaceReturnPath, + getWorkspaceLoginUrl, + readWorkspaceReturnPath, + storeReturnPath, +} from '$lib/server/workspace-auth'; + +export const load: PageServerLoad = async ({ url, cookies, fetch }) => { + // Obtener el código y state de los query params + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + const errorParam = url.searchParams.get('error'); + const errorDescription = url.searchParams.get('error_description'); + + if (errorParam) { + console.error('❌ [Callback Server] KC auth error:', errorParam, errorDescription); + // login_required means no KC session exists yet → send to Workspace login. + // Preserve the intended destination through the detour so /login can pick it up. + if (state) { + try { + const stateObj = JSON.parse(state); + const returnPath = stateObj.redirect_url; + if (returnPath && returnPath.startsWith('/') && returnPath !== '/login') { + storeReturnPath(cookies, returnPath); + } + } catch { /* ignore malformed state */ } + } + throw redirect(303, getWorkspaceLoginUrl(url.origin)); + } + + if (!code) { + console.error('❌ [Callback Server] No se recibió código de autorización'); + throw redirect(303, getWorkspaceLoginUrl(url.origin)); + } + + try { + // Intercambiar código por tokens usando el backend de Keycloak + // En el servidor (SSR), usar KEYCLOAK_URL que apunta a http://keycloak:8080 + // En producción o fuera de Docker, usar VITE_KEYCLOAK_URL como fallback + const KEYCLOAK_URL = process.env.KEYCLOAK_URL || process.env.VITE_KEYCLOAK_URL || 'http://localhost:8080'; + const KEYCLOAK_REALM = process.env.KEYCLOAK_REALM || process.env.VITE_KEYCLOAK_REALM || 'master'; + const KEYCLOAK_CLIENT_ID = process.env.KEYCLOAK_CLIENT_ID || process.env.VITE_KEYCLOAK_CLIENT_ID || 'app-backend'; + const KEYCLOAK_CLIENT_SECRET = process.env.KEYCLOAK_CLIENT_SECRET || ''; + + // La redirect_uri debe coincidir exactamente con la registrada en Keycloak. + // resolveSystemBaseUrl corrige el caso donde url.origin es localhost porque + // ORIGIN env var apunta a localhost en producción (usa SITE_URL como fallback). + const { resolveSystemBaseUrl } = await import('$lib/server/workspace-auth'); + const redirectUri = `${resolveSystemBaseUrl(url.origin)}/auth/callback`; + + const tokenEndpoint = `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token`; + + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code: code, + redirect_uri: redirectUri, + client_id: KEYCLOAK_CLIENT_ID, + ...(KEYCLOAK_CLIENT_SECRET && { client_secret: KEYCLOAK_CLIENT_SECRET }) + }); + + const tokenResponse = await fetch(tokenEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: body.toString() + }); + + if (!tokenResponse.ok) { + const errorData = await tokenResponse.text(); + console.error('❌ [Callback Server] Error al intercambiar código:', errorData); + throw new Error('Error al obtener tokens'); + } + + const tokens = await tokenResponse.json(); + + // Establecer las cookies en el servidor + // access_token → NO HttpOnly (el cliente JS lo usa para el header Authorization) + // refresh_token → HttpOnly (el JS nunca lo lee; el servidor lo gestiona) + const { isSecureContext } = await import('$lib/server/workspace-auth'); + const isProduction = isSecureContext(); + + setAccessTokenCookies(cookies, tokens.access_token, { + secure: isProduction, + maxAge: 60 * 60 * 24 * 7 // 7 días + }); + + if (tokens.refresh_token) { + cookies.set('refresh_token', tokens.refresh_token, { + path: '/', + httpOnly: true, // *** HttpOnly: nunca expuesto a JS *** + secure: isProduction, + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 30 // 30 días + }); + } + + if (tokens.id_token) { + cookies.set('id_token', tokens.id_token, { + path: '/', + httpOnly: true, + secure: isProduction, + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 7 + }); + } + + // Obtener la URL de redirección del state o ir al dashboard + let redirectTo = readWorkspaceReturnPath(cookies, '/dashboard'); + if (state) { + try { + const stateObj = JSON.parse(state); + redirectTo = stateObj.redirect_url || '/dashboard'; + } catch (e) { + console.warn('⚠️ [Callback Server] No se pudo obtener redirect_url del state'); + } + } + + clearWorkspaceReturnPath(cookies); + + // Redirigir a la página de destino + throw redirect(303, redirectTo); + + } catch (err: any) { + if (isRedirect(err)) throw err; + console.error('❌ [Callback Server] Error procesando autenticación:', err); + throw redirect(303, getWorkspaceLoginUrl(url.origin)); + } +}; diff --git a/frontend/src/routes/auth/callback/+page.svelte b/frontend/src/routes/auth/callback/+page.svelte new file mode 100644 index 0000000..27711a6 --- /dev/null +++ b/frontend/src/routes/auth/callback/+page.svelte @@ -0,0 +1,20 @@ + + +
    +
    +
    +

    + Procesando autenticación... +

    + +
    +
    +
    +

    + Espera un momento mientras completamos tu inicio de sesión... +

    +
    +
    +
    diff --git a/frontend/src/routes/auth/post-logout/+server.ts b/frontend/src/routes/auth/post-logout/+server.ts new file mode 100644 index 0000000..5c3ba8d --- /dev/null +++ b/frontend/src/routes/auth/post-logout/+server.ts @@ -0,0 +1,13 @@ +import { redirect } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getWorkspaceLoginUrl } from '$lib/server/workspace-auth'; + +/** + * KC redirects here after completing the logout flow. + * This URL is covered by the app's registered wildcard in KC (e.g. mi-app.dominio.com/*). + * We then send the user to workspace login so it can apply myApps() launcher logic. + */ +export const GET: RequestHandler = async ({ request, url }) => { + const systemBaseUrl = url.origin; + throw redirect(303, getWorkspaceLoginUrl(systemBaseUrl, { forPostLogout: true })); +}; diff --git a/frontend/src/routes/auth/sso/+page.server.ts b/frontend/src/routes/auth/sso/+page.server.ts new file mode 100644 index 0000000..e56cdaf --- /dev/null +++ b/frontend/src/routes/auth/sso/+page.server.ts @@ -0,0 +1,212 @@ +/** + * SSO auto-login page for Mi Aplicación. + * The Hub App Launcher redirects here with ?relay= after generating a relay token. + * This server-side load function exchanges the relay token for KC tokens via the + * Hub backend, sets HttpOnly cookies, and redirects to /dashboard. + */ +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; +import { setAccessTokenCookies } from '$lib/server/access-token-cookie'; +import { redirectToWorkspaceLogin } from '$lib/server/workspace-auth'; +import { isValidSystem, setActiveSystemCookie } from '$lib/server/system-gate'; + +// Disable client-side rendering to prevent SvelteKit from making a second +// __data.json request that would consume the one-time relay token twice. +export const csr = false; + +export const load: PageServerLoad = async ({ url, cookies }) => { + const relayToken = url.searchParams.get('relay'); + const requestedSystem = url.searchParams.get('active_system'); + console.log('[SSO] relay token presente:', !!relayToken, '| active_system:', requestedSystem ?? '(none)'); + + if (!relayToken) { + redirectToWorkspaceLogin(cookies, url); + } + + // Limpiar sesión anterior para que el nuevo usuario reciba sus propias cookies. + // No se omite el exchange aunque haya token existente — la sesión podría ser + // de otro usuario (ej: juan que hace logout e ingresa como lal17). + // La única excepción es si el relay token ya fue consumido (lo maneja el error handler). + { + const { clearAccessTokenCookies } = await import('$lib/server/access-token-cookie'); + clearAccessTokenCookies(cookies); + cookies.delete('refresh_token', { path: '/' }); + cookies.delete('id_token', { path: '/' }); + cookies.delete('active_company_id', { path: '/' }); + } + + // SSO exchange must call the Hub that GENERATED the relay token. + // This fetch runs server-side (inside the Docker container), so we must use + // INTERNAL_HUB_URL (host.docker.internal) when available — "localhost" inside + // a container never reaches the host where the workspace Hub is running. + const hubUrl = ( + process.env.INTERNAL_HUB_URL || + process.env.HUB_URL || + process.env.VITE_HUB_URL || + 'http://localhost:8001' + ).replace(/\/+$/, ''); + const baseUrl = `${hubUrl}/`; + + let response: Response; + try { + response = await fetch(`${baseUrl}api/v1/auth/sso-exchange`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ relay_token: relayToken }), + }); + } catch (err) { + redirectToWorkspaceLogin(cookies, url); + } + + if (!response.ok) { + const body = await response.json().catch(() => ({})); + const detail: string = body?.detail || 'sso_exchange_failed'; + console.error('[SSO] exchange falló:', response.status, detail); + + // If the token is "invalid/used", a concurrent request may have already + // succeeded and set cookies. Redirect to /dashboard — if the session is + // valid it will load; if not, the dashboard layout will redirect to /login. + const tokenAlreadyUsed = + detail.toLowerCase().includes('inválido') || + detail.toLowerCase().includes('invalido') || + detail.toLowerCase().includes('invalid') || + detail.toLowerCase().includes('used') || + detail.toLowerCase().includes('expired'); + if (tokenAlreadyUsed) { + throw redirect(303, '/dashboard'); + } + + redirectToWorkspaceLogin(cookies, url); + } + + let tokens: Record; + try { + tokens = await response.json(); + } catch (err) { + console.error('[SSO] exchange devolvió body no-JSON (status 200):', err); + redirectToWorkspaceLogin(cookies, url); + } + + if (!tokens.access_token || typeof tokens.access_token !== 'string') { + console.error('[SSO] exchange exitoso pero access_token faltante o inválido:', tokens); + redirectToWorkspaceLogin(cookies, url); + } + + console.log('[SSO] exchange exitoso, tokens recibidos:', { + hasAccessToken: !!tokens.access_token, + accessTokenLen: (tokens.access_token as string).length, + hasRefreshToken: !!tokens.refresh_token, + tenant_id: tokens.tenant_id, + tenant_slug: tokens.tenant_slug, + }); + + // ── Refresh proactivo ──────────────────────────────────────────────────── + // Los tokens del relay fueron emitidos por KC via el browser (iss=IP:8085). + // El Hub backend valida contra KC interno (hub-keycloak:8080) → issuer mismatch → 401. + // Refrescando aquí: Mi Aplicación backend → Hub → KC interno → iss=hub-keycloak:8080 → válido. + if (typeof tokens.refresh_token === 'string') { + try { + const internalApiUrl = ( + process.env.INTERNAL_API_URL || + process.env.VITE_API_URL || + 'http://backend:8000/api/' + ).replace(/\/+$/, ''); + const refreshRes = await fetch(`${internalApiUrl}/v1/auth/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: tokens.refresh_token }), + }); + if (refreshRes.ok) { + const refreshed = await refreshRes.json().catch(() => ({})); + if (refreshed.access_token && refreshed.refresh_token) { + tokens = { ...tokens, ...refreshed }; + console.log('[SSO] tokens refrescados exitosamente (iss normalizado)'); + } + } else { + console.warn('[SSO] refresh proactivo falló (status', refreshRes.status, ') — usando tokens originales del relay'); + } + } catch (err) { + console.warn('[SSO] refresh proactivo error (non-blocking):', err); + } + } + + const { isSecureContext } = await import('$lib/server/workspace-auth'); + const isProduction = isSecureContext(); + console.log('[SSO] ORIGIN-based secure context:', isProduction); + + // access_token — NO HttpOnly (Bearer desde JS); fragmentado si el JWT supera ~4KB + setAccessTokenCookies(cookies, tokens.access_token as string, { + secure: isProduction, + maxAge: 60 * 60 * 24 * 7, + }); + + // refresh_token — HttpOnly (never exposed to JS) + if (typeof tokens.refresh_token === 'string') { + cookies.set('refresh_token', tokens.refresh_token, { + path: '/', + httpOnly: true, + secure: isProduction, + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 30, + }); + } + + // id_token — requerido para id_token_hint en el logout de Keycloak. + // Puede venir del refresh proactivo o del exchange original. + if (typeof tokens.id_token === 'string') { + cookies.set('id_token', tokens.id_token, { + path: '/', + httpOnly: true, + secure: isProduction, + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 7, + }); + } + + // sso_tenant_id — HttpOnly cookie con el tenant seleccionado. + // El backend lo pasa como X-Tenant-Override en Hub /auth/me para que + // devuelva el tenant correcto aunque el KC token tenga otro tenant baked in. + if (typeof tokens.tenant_id === 'number') { + cookies.set('sso_tenant_id', String(tokens.tenant_id), { + path: '/', + httpOnly: true, + secure: isProduction, + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 7, + }); + // sso_tenant_pub — companion no-HttpOnly para que el cliente JS pueda + // leer el tenant override e incluirlo como X-Tenant-Override en fetch directo al backend. + // No es un secreto (solo un ID numérico; Hub valida UserTenant en cada request). + cookies.set('sso_tenant_pub', String(tokens.tenant_id), { + path: '/', + httpOnly: false, + secure: isProduction, + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 7, + }); + } + console.log('[SSO] cookies configuradas, preparando redirect a /dashboard con active_system:', requestedSystem ?? '(none)'); + + // Ejecutar lazy-link server-side: crear UserTenant si hay invite pendiente. + // Se llama con el Bearer token recién obtenido. Best-effort, no bloquea el SSO. + try { + const internalApiUrl = ( + process.env.INTERNAL_API_URL || + process.env.VITE_API_URL || + 'http://backend:8000/api/' + ).replace(/\/+$/, ''); + await fetch(`${internalApiUrl}/v1/auth/lazy-link`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${tokens.access_token as string}`, + 'Content-Type': 'application/json', + }, + }).catch(() => {}); + } catch { /* non-blocking */ } + + if (isValidSystem(requestedSystem)) { + setActiveSystemCookie(cookies, requestedSystem); + } + + throw redirect(303, '/dashboard'); +}; diff --git a/frontend/src/routes/auth/sso/+page.svelte b/frontend/src/routes/auth/sso/+page.svelte new file mode 100644 index 0000000..e40b13b --- /dev/null +++ b/frontend/src/routes/auth/sso/+page.svelte @@ -0,0 +1,11 @@ + + +
    +
    +
    +

    Iniciando sesión automáticamente…

    +
    +
    diff --git a/frontend/src/routes/dashboard/+layout.server.ts b/frontend/src/routes/dashboard/+layout.server.ts new file mode 100644 index 0000000..a3f98a9 --- /dev/null +++ b/frontend/src/routes/dashboard/+layout.server.ts @@ -0,0 +1,73 @@ +import { env } from '$env/dynamic/private'; +import type { LayoutServerLoad } from './$types'; +import { + validateAuth, + getAuthTokens, + getUserCompanies, + clearAuthTokens +} from '$lib/server/api'; +import { redirectToWorkspaceLogin } from '$lib/server/workspace-auth'; +import { resolveActiveCompanyId } from '$lib/server/system-gate'; +import { fetchMyApps } from '$lib/server/workspace-apps'; + +const DEV_LOCAL_AUTH = (env.DEV_LOCAL_AUTH ?? '').toLowerCase() === 'true'; + +export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => { + const { accessToken } = getAuthTokens(cookies); + + if (!accessToken) { + redirectToWorkspaceLogin(cookies, url); + } + + try { + // my-companies primero: ejecuta get_current_user y provisiona tenant/usuario si aplica. + const companies = await getUserCompanies(cookies, fetch); + const userData = await validateAuth(cookies, fetch, undefined); + const activeCompanyId = resolveActiveCompanyId(cookies, companies); + + // Modo local: no hay Hub. Saltar fetch de tenants/apps (evita timeouts por navegación). + let userTenants: { id: number; name: string; slug: string }[] = []; + let myApps: { apps: unknown[]; routing: unknown } = { apps: [], routing: null }; + + if (!DEV_LOCAL_AUTH) { + try { + const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, ''); + const tenantOverride = cookies.get('sso_tenant_id'); + const tenantsRes = await fetch(`${hubUrl}/api/v1/auth/my-tenants`, { + headers: { + 'Authorization': `Bearer ${accessToken}`, + ...(tenantOverride ? { 'X-Tenant-Override': tenantOverride } : {}) + } + }); + if (tenantsRes.ok) { + userTenants = await tenantsRes.json(); + } + } catch { + // No bloquear el dashboard si falla la carga de tenants + } + + const freshAccessToken = getAuthTokens(cookies).accessToken ?? accessToken; + myApps = await fetchMyApps(freshAccessToken, fetch, cookies.get('sso_tenant_id')); + } + + return { + authenticated: true, + user: { ...userData, token: accessToken }, + companies, + activeCompanyId: activeCompanyId ?? undefined, + userTenants, + workspaceApps: myApps.apps, + appRouting: myApps.routing, + error: undefined + }; + } catch (error) { + // Si es un redirect, re-lanzarlo sin tocar las cookies + if (error && typeof error === 'object' && 'status' in error && 'location' in error) { + throw error; + } + + // Cualquier otro error: limpiar token y redirigir al login + clearAuthTokens(cookies); + redirectToWorkspaceLogin(cookies, url); + } +}; diff --git a/frontend/src/routes/dashboard/+layout.svelte b/frontend/src/routes/dashboard/+layout.svelte new file mode 100644 index 0000000..22ecb95 --- /dev/null +++ b/frontend/src/routes/dashboard/+layout.svelte @@ -0,0 +1,220 @@ + + +{#if data.licenseError} + +{:else} + + + +
    +
    + + +
    + {#if workspaceAppsStore.hasApps} +
    + +
    + {/if} +
    +
    + {@render children?.()} +
    +
    +
    + + +{/if} diff --git a/frontend/src/routes/dashboard/+layout.ts b/frontend/src/routes/dashboard/+layout.ts new file mode 100644 index 0000000..c20252f --- /dev/null +++ b/frontend/src/routes/dashboard/+layout.ts @@ -0,0 +1,16 @@ +import type { LayoutLoad } from './$types'; + +export const load: LayoutLoad = async ({ data }) => { + // Pasar los datos del servidor al cliente + return { + user: data.user, + companies: data.companies, + authenticated: data.authenticated, + userTenants: data.userTenants ?? [], + activeCompanyId: data.activeCompanyId, + activeSystem: data.activeSystem ?? null, + allowedSystems: data.allowedSystems ?? [], + workspaceApps: data.workspaceApps ?? [], + appRouting: data.appRouting ?? '' + }; +}; diff --git a/frontend/src/routes/dashboard/+page.svelte b/frontend/src/routes/dashboard/+page.svelte new file mode 100644 index 0000000..9323e5d --- /dev/null +++ b/frontend/src/routes/dashboard/+page.svelte @@ -0,0 +1,45 @@ + + +
    +
    +

    + + Dashboard +

    +

    + Plantilla base — agrega tus módulos aquí. +

    +
    + +
    + +
    +
    + +
    +
    +

    Compañía activa

    +

    + {companyStore.activeCompany?.name ?? '—'} +

    +
    +
    + + + +
    + +
    +
    +

    Mi cuenta

    +

    Perfil y configuración

    +
    +
    +
    +
    diff --git a/frontend/src/routes/dashboard/account/+page.server.ts b/frontend/src/routes/dashboard/account/+page.server.ts new file mode 100644 index 0000000..d4e06e5 --- /dev/null +++ b/frontend/src/routes/dashboard/account/+page.server.ts @@ -0,0 +1,6 @@ +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ parent }) => { + const { user } = await parent(); + return { user }; +}; diff --git a/frontend/src/routes/dashboard/account/+page.svelte b/frontend/src/routes/dashboard/account/+page.svelte new file mode 100644 index 0000000..95dd005 --- /dev/null +++ b/frontend/src/routes/dashboard/account/+page.svelte @@ -0,0 +1,30 @@ + + +
    +
    +

    + + Mi cuenta +

    +

    Perfil y configuración personal.

    +
    + + + Perfil + + + {#if data.user} +
    Nombre: {data.user.name ?? data.user.preferred_username ?? '—'}
    +
    Email: {data.user.email ?? '—'}
    + {:else} +

    No hay datos de usuario disponibles.

    + {/if} +
    +
    +
    diff --git a/frontend/src/routes/dashboard/roles/+page.server.ts b/frontend/src/routes/dashboard/roles/+page.server.ts new file mode 100644 index 0000000..45fd039 --- /dev/null +++ b/frontend/src/routes/dashboard/roles/+page.server.ts @@ -0,0 +1,5 @@ +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async () => { + return {}; +}; diff --git a/frontend/src/routes/dashboard/roles/+page.svelte b/frontend/src/routes/dashboard/roles/+page.svelte new file mode 100644 index 0000000..938eb89 --- /dev/null +++ b/frontend/src/routes/dashboard/roles/+page.svelte @@ -0,0 +1,26 @@ + + +
    +
    +

    + + Roles y permisos +

    +

    + Gestión de roles y control de acceso. +

    +
    + + + + Roles del sistema + Implementa aquí la gestión de roles y permisos de tu proyecto. + + +

    Sección en construcción.

    +
    +
    +
    diff --git a/frontend/src/routes/dashboard/settings/general/+page.svelte b/frontend/src/routes/dashboard/settings/general/+page.svelte new file mode 100644 index 0000000..0f1206d --- /dev/null +++ b/frontend/src/routes/dashboard/settings/general/+page.svelte @@ -0,0 +1,30 @@ + + + + Configuración General + + +
    +
    +

    + + Configuración General +

    +

    + Ajustes globales de la aplicación. +

    +
    + + + + Configuración del sistema + Agrega aquí los ajustes de configuración de tu proyecto. + + +

    Sección en construcción.

    +
    +
    +
    diff --git a/frontend/src/routes/dashboard/settings/general/+page.ts b/frontend/src/routes/dashboard/settings/general/+page.ts new file mode 100644 index 0000000..a3d1578 --- /dev/null +++ b/frontend/src/routes/dashboard/settings/general/+page.ts @@ -0,0 +1 @@ +export const ssr = false; diff --git a/frontend/src/routes/dashboard/tasks/+page.ts b/frontend/src/routes/dashboard/tasks/+page.ts new file mode 100644 index 0000000..ea941f2 --- /dev/null +++ b/frontend/src/routes/dashboard/tasks/+page.ts @@ -0,0 +1,6 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageLoad } from './$types'; + +export const load: PageLoad = () => { + throw redirect(303, '/dashboard'); +}; diff --git a/frontend/src/routes/dashboard/users/+page.server.ts b/frontend/src/routes/dashboard/users/+page.server.ts new file mode 100644 index 0000000..45fd039 --- /dev/null +++ b/frontend/src/routes/dashboard/users/+page.server.ts @@ -0,0 +1,5 @@ +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async () => { + return {}; +}; diff --git a/frontend/src/routes/dashboard/users/+page.svelte b/frontend/src/routes/dashboard/users/+page.svelte new file mode 100644 index 0000000..4ecfca5 --- /dev/null +++ b/frontend/src/routes/dashboard/users/+page.svelte @@ -0,0 +1,23 @@ + + +
    +
    +

    + + Usuarios +

    +

    Gestión de usuarios y accesos.

    +
    + + + Usuarios del sistema + Implementa aquí la gestión de usuarios de tu proyecto. + + +

    Sección en construcción.

    +
    +
    +
    diff --git a/frontend/src/routes/demo/+page.svelte b/frontend/src/routes/demo/+page.svelte new file mode 100644 index 0000000..a815390 --- /dev/null +++ b/frontend/src/routes/demo/+page.svelte @@ -0,0 +1 @@ +paraglide diff --git a/frontend/src/routes/demo/paraglide/+page.svelte b/frontend/src/routes/demo/paraglide/+page.svelte new file mode 100644 index 0000000..f7d5684 --- /dev/null +++ b/frontend/src/routes/demo/paraglide/+page.svelte @@ -0,0 +1,16 @@ + + + + +

    {m.hello_world({ name: 'SvelteKit User' })}

    +
    + + +

    +If you use VSCode, install the Sherlock i18n extension for a better i18n experience. +

    diff --git a/frontend/src/routes/join/+page.server.ts b/frontend/src/routes/join/+page.server.ts new file mode 100644 index 0000000..bd58d8b --- /dev/null +++ b/frontend/src/routes/join/+page.server.ts @@ -0,0 +1,85 @@ +import { redirect, fail } from '@sveltejs/kit'; +import type { PageServerLoad, Actions } from './$types'; +import { getServerApiUrl, getAuthTokens } from '$lib/server/api'; +import { redirectToKeycloakLogin } from '$lib/server/workspace-auth'; + +export const load: PageServerLoad = async ({ url, cookies, fetch }) => { + const code = url.searchParams.get('code')?.toUpperCase().trim() ?? ''; + const step = url.searchParams.get('step') ?? ''; + + // Paso 3: usuario volvió de Keycloak, consumir el código + if (code && step === 'consume') { + const { accessToken } = getAuthTokens(cookies); + + if (!accessToken) { + // Sesión KC expiró entre redirecciones — volver a auth + redirectToKeycloakLogin(url.origin, `/join?code=${code}&step=consume`); + } + + const apiUrl = getServerApiUrl(); + + // Consumir el código + const consumeRes = await fetch(`${apiUrl}v1/core/invite-codes/consume/${code}`, { + method: 'POST', + headers: { Authorization: `Bearer ${accessToken}` } + }); + + if (!consumeRes.ok) { + const body = await consumeRes.json().catch(() => ({})); + return { + step: 'preview', + code, + codeInfo: null, + error: body?.detail ?? 'No se pudo canjear el código. Intenta de nuevo.' + }; + } + + const result = await consumeRes.json(); + return { step: 'success', code, result, error: null, codeInfo: null }; + } + + // Paso 2: hay código en la URL (viene de validar), mostrar preview + if (code) { + const apiUrl = getServerApiUrl(); + const validateRes = await fetch(`${apiUrl}v1/core/invite-codes/validate/${code}`); + + if (!validateRes.ok) { + return { step: 'input', code: '', error: 'Código inválido, expirado o agotado.', codeInfo: null }; + } + + const codeInfo = await validateRes.json(); + return { step: 'preview', code, codeInfo, error: null }; + } + + return { step: 'input', code: '', error: null, codeInfo: null }; +}; + +export const actions: Actions = { + // Valida el código y redirige a la URL con ?code=XXX para el preview + validate: async ({ request }) => { + const data = await request.formData(); + const code = (data.get('code') as string ?? '').toUpperCase().trim(); + + if (!code) return fail(422, { error: 'Ingresa un código de invitación.' }); + + redirect(303, `/join?code=${code}`); + }, + + // Inicia el join: si hay sesión, consume; si no, va a KC login + join: async ({ request, cookies, url, fetch }) => { + const data = await request.formData(); + const code = (data.get('code') as string ?? '').toUpperCase().trim(); + + if (!code) return fail(422, { error: 'Código inválido.' }); + + const { accessToken } = getAuthTokens(cookies); + + if (!accessToken) { + // Redirigir a Keycloak; al volver, el callback irá a /join?code=XXX&step=consume + redirectToKeycloakLogin(url.origin, `/join?code=${code}&step=consume`); + } + + // Si ya hay sesión, consumir directamente vía redirect a step=consume + redirect(303, `/join?code=${code}&step=consume`); + } +}; diff --git a/frontend/src/routes/join/+page.svelte b/frontend/src/routes/join/+page.svelte new file mode 100644 index 0000000..a5f31e5 --- /dev/null +++ b/frontend/src/routes/join/+page.svelte @@ -0,0 +1,149 @@ + + +
    +
    + + +
    +
    + + + +
    +
    +

    Mi Aplicación

    +

    Unirse a una empresa

    +

    Ingresa el código que te compartió tu administrador.

    +
    +
    + + + {#if data.step === 'input'} +
    +
    + + {#if error} +

    {error}

    + {/if} +
    + +
    + + + {:else if data.step === 'preview' && data.codeInfo} +
    + +
    +
    + Código + {data.code} +
    +
    + Workspace + {data.codeInfo.tenant_slug} +
    +
    + Rol asignado + + {data.codeInfo.role} + +
    + {#if data.codeInfo.remaining_uses !== null} +
    + Usos restantes + {data.codeInfo.remaining_uses} +
    + {/if} + {#if data.codeInfo.expires_at} +
    + Vence + + {new Date(data.codeInfo.expires_at).toLocaleDateString('es-MX', { day: '2-digit', month: 'short', year: 'numeric' })} + +
    + {/if} +
    + + {#if error} +

    + {error} +

    + {/if} + +
    + + +
    + + Usar otro código + +
    + + + {:else if data.step === 'preview'} +
    +

    + {error ?? 'Código inválido, expirado o agotado. Verifica con tu administrador.'} +

    + ← Intentar con otro código +
    + + + {:else if data.step === 'success'} +
    +
    +
    + + + +
    +
    +
    +

    ¡Bienvenido!

    +

    + Te uniste al workspace {data.result?.tenant_slug} + {#if data.result?.company_id} + como {data.result?.role}. + {/if} +

    +
    + + Ir al dashboard → + +
    + {/if} + +
    +
    diff --git a/frontend/src/routes/login/+page.server.ts b/frontend/src/routes/login/+page.server.ts new file mode 100644 index 0000000..66d5d5f --- /dev/null +++ b/frontend/src/routes/login/+page.server.ts @@ -0,0 +1,117 @@ +import { redirect, fail, isRedirect } from '@sveltejs/kit'; +import { env } from '$env/dynamic/private'; +import type { Actions, PageServerLoad } from './$types'; +import { clearAuthTokens, getAuthTokens } from '$lib/server/api'; +import { setAccessTokenCookies } from '$lib/server/access-token-cookie'; +import { + getWorkspaceLoginUrl, + readWorkspaceReturnPath, + clearWorkspaceReturnPath, + storeReturnPath, + redirectToKeycloakAuthorization, + redirectToKeycloakLogin, + getHubBackendUrl, + isSecureContext, +} from '$lib/server/workspace-auth'; + +const DEV_LOCAL_AUTH = (env.DEV_LOCAL_AUTH ?? '').toLowerCase() === 'true'; + +export const load: PageServerLoad = async ({ cookies, url }) => { + // Modo local: mostrar el form de login sin redirigir al workspace + if (DEV_LOCAL_AUTH) { + // Solo limpiar si ya hay token (re-login explícito), no en cada carga + const { accessToken } = getAuthTokens(cookies); + if (accessToken) clearAuthTokens(cookies); + return { devMode: true }; + } + + // Relay SSO: el Hub App Launcher redirige aquí con ?relay=UUID4. + const relayToken = url.searchParams.get('relay'); + if (relayToken) { + try { + const hubBackendUrl = getHubBackendUrl(); + const exchangeRes = await fetch(`${hubBackendUrl}/api/v1/auth/sso-exchange`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ relay_token: relayToken }) + }); + + if (exchangeRes.ok) { + const data = await exchangeRes.json(); + const isProduction = isSecureContext(); + setAccessTokenCookies(cookies, data.access_token, { + secure: isProduction, + maxAge: 60 * 60 * 24 * 7 + }); + if (data.refresh_token) { + cookies.set('refresh_token', data.refresh_token, { + path: '/', + httpOnly: true, + secure: isProduction, + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 30 + }); + } + const redirectTo = + url.searchParams.get('redirect') || + readWorkspaceReturnPath(cookies, '/dashboard'); + clearWorkspaceReturnPath(cookies); + throw redirect(303, redirectTo); + } + } catch (err) { + if (isRedirect(err)) throw err; + } + } + + clearAuthTokens(cookies); + + if (url.searchParams.get('sso_verified') === '1') { + const existingReturnPath = readWorkspaceReturnPath(cookies, ''); + const intendedPath = + existingReturnPath && existingReturnPath !== '/login' + ? existingReturnPath + : (url.searchParams.get('redirect') || '/dashboard'); + storeReturnPath(cookies, intendedPath); + redirectToKeycloakAuthorization(url.origin, intendedPath); + } + + const redirectParam = url.searchParams.get('redirect'); + if (redirectParam) { + const existingReturnPath = readWorkspaceReturnPath(cookies, ''); + const intendedPath = + existingReturnPath && existingReturnPath !== '/login' + ? existingReturnPath + : redirectParam; + storeReturnPath(cookies, intendedPath); + redirectToKeycloakLogin(url.origin, intendedPath); + } + + storeReturnPath(cookies, '/dashboard'); + throw redirect(303, getWorkspaceLoginUrl(url.origin)); +}; + +export const actions: Actions = { + dev_login: async ({ cookies, request }) => { + if (!DEV_LOCAL_AUTH) throw redirect(303, '/login'); + + const BACKEND_URL = env.BACKEND_URL || 'http://backend:8000'; + + const res = await fetch(`${BACKEND_URL}/api/v1/auth/dev-login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + + if (!res.ok) { + return fail(500, { error: 'No se pudo generar el token local. Verifica que DEV_LOCAL_AUTH=true en el backend.' }); + } + + const { access_token } = await res.json(); + + setAccessTokenCookies(cookies, access_token, { + secure: false, + maxAge: 60 * 60 * 8 + }); + + throw redirect(303, '/dashboard'); + } +}; diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte new file mode 100644 index 0000000..c3caf0a --- /dev/null +++ b/frontend/src/routes/login/+page.svelte @@ -0,0 +1,37 @@ + + +{#if data.devMode} +
    + + +
    + +
    + Modo desarrollo + + Login local activo — sin Keycloak ni Hub. + +
    + + +
    + +
    + +

    + Para usar el workspace, quita DEV_LOCAL_AUTH=true del entorno. +

    +
    +
    +
    +{/if} diff --git a/frontend/src/routes/logout/+server.ts b/frontend/src/routes/logout/+server.ts new file mode 100644 index 0000000..8e692e7 --- /dev/null +++ b/frontend/src/routes/logout/+server.ts @@ -0,0 +1,37 @@ +import { redirect } from '@sveltejs/kit'; +import { env } from '$env/dynamic/private'; +import type { RequestHandler } from './$types'; +import { clearAccessTokenCookies } from '$lib/server/access-token-cookie'; +import { + buildKeycloakLogoutUrl, + clearWorkspaceReturnPath, + getWorkspaceLoginUrl +} from '$lib/server/workspace-auth'; + +export const POST: RequestHandler = async ({ cookies, url }) => { + const systemBaseUrl = url.origin; + + const idToken = cookies.get('id_token'); + + // Eliminar todas las cookies de autenticación + clearAccessTokenCookies(cookies); + cookies.delete('refresh_token', { path: '/' }); + cookies.delete('id_token', { path: '/' }); + cookies.delete('active_company_id', { path: '/' }); + cookies.delete('active_system', { path: '/' }); + cookies.delete('sso_tenant_id', { path: '/' }); + cookies.delete('sso_tenant_pub', { path: '/' }); + clearWorkspaceReturnPath(cookies); + + // En modo local no hay Keycloak ni Hub — ir directo al login local. + if ((env.DEV_LOCAL_AUTH ?? '').toLowerCase() === 'true') { + throw redirect(303, '/login'); + } + + // Sin id_token_hint KC rechaza post_logout_redirect_uri no registrado. + if (!idToken) { + throw redirect(303, getWorkspaceLoginUrl(systemBaseUrl, { forPostLogout: true })); + } + + throw redirect(303, buildKeycloakLogoutUrl(systemBaseUrl, idToken)); +}; diff --git a/frontend/src/routes/page.svelte.spec.ts b/frontend/src/routes/page.svelte.spec.ts new file mode 100644 index 0000000..26d2017 --- /dev/null +++ b/frontend/src/routes/page.svelte.spec.ts @@ -0,0 +1,13 @@ +import { page } from '@vitest/browser/context'; +import { describe, expect, it } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import Page from './+page.svelte'; + +describe('/+page.svelte', () => { + it('should render h1', async () => { + render(Page); + + const heading = page.getByRole('heading', { level: 1 }); + await expect.element(heading).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/routes/register/+page.svelte b/frontend/src/routes/register/+page.svelte new file mode 100644 index 0000000..128aeac --- /dev/null +++ b/frontend/src/routes/register/+page.svelte @@ -0,0 +1,404 @@ + + +
    +
    + +
    +

    Crear cuenta

    +

    + ¿Ya tienes una cuenta? + + Inicia sesión + +

    +
    + + + {#if isInviteFlow} +
    +
    + + + +
    +

    Invitación válida

    +

    + Estás registrándote en {inviteTenantSlug}. + El enlace caduca en 48 horas y es de un solo uso. +

    +
    +
    +
    + {/if} + + + {#if isInviteFlow && checkLoading} +
    +

    Verificando invitación...

    +
    + + + {:else if isInviteFlow && checkError} +
    +
    +

    Invitación inválida

    +

    {checkError}

    +
    + + Volver al inicio + +
    + + + {:else if success} +
    +
    + + + +
    +

    ¡Cuenta creada!

    +

    + {#if userExists} + Tu cuenta ha sido vinculada a {inviteTenantSlug}. + {:else} + Te hemos enviado un correo de verificación a {formData.email}. + Confirma tu email antes de iniciar sesión. + {/if} +

    +

    Redirigiendo al login...

    + + Ir al login + +
    + + + {:else if !isInviteFlow || (isInviteFlow && checkDone)} + +
    +
    +
    + + + {#if userExists} +
    +

    + Ya tienes una cuenta en el sistema. Al confirmar quedarás vinculado a + {inviteTenantSlug}. +

    +
    + {/if} + + + {#if !userExists} +
    + + +

    Solo letras minúsculas, números, puntos y guiones.

    +
    + {/if} + + +
    + + + {#if isInviteFlow && inviteEmail} +

    El email está fijado por la invitación.

    + {/if} +
    + + + {#if !userExists} +
    +
    + + +
    +
    + + +
    +
    + + +
    + + +
    + + +
    + + + {#if passwordError} +

    {passwordError}

    + {/if} +
    + {/if} + + +
    + + {#if isInviteFlow} + +

    Fijado por la invitación.

    + {:else} + + {/if} +
    + + + {#if error} +
    +

    {error}

    +
    + {/if} + + +
    + + +
    +
    + + +
    +

    + Al registrarte, aceptas nuestros términos de servicio y política de privacidad. +

    +
    +
    +
    + {/if} +
    +
    diff --git a/frontend/src/svelte-shims.d.ts b/frontend/src/svelte-shims.d.ts new file mode 100644 index 0000000..185afbc --- /dev/null +++ b/frontend/src/svelte-shims.d.ts @@ -0,0 +1,7 @@ +// Ambient type declarations for .svelte files +// This must be a script (no top-level import/export) to be globally ambient +declare module "*.svelte" { + import type { Component } from "svelte"; + const component: Component; + export default component; +} diff --git a/frontend/static/login-bg.jpg b/frontend/static/login-bg.jpg new file mode 100644 index 0000000..9802369 Binary files /dev/null and b/frontend/static/login-bg.jpg differ diff --git a/frontend/static/silent-check-sso.html b/frontend/static/silent-check-sso.html new file mode 100644 index 0000000..455d67b --- /dev/null +++ b/frontend/static/silent-check-sso.html @@ -0,0 +1,14 @@ + + + + Silent SSO Check + + + + + diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js new file mode 100644 index 0000000..72124de --- /dev/null +++ b/frontend/svelte.config.js @@ -0,0 +1,17 @@ +import adapter from '@sveltejs/adapter-node'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + // Consult https://svelte.dev/docs/kit/integrations + // for more information about preprocessors + preprocess: vitePreprocess(), + kit: { + adapter: adapter(), + csrf: { + trustedOrigins: (process.env.TRUSTED_ORIGINS ?? process.env.CORS_ORIGINS ?? '').split(',').filter(Boolean) + } + } +}; + +export default config; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..fb43d32 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,38 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler", + "allowArbitraryExtensions": true + }, + "include": [ + "playwright.config.ts", + "e2e/**/*.ts", + "e2e/**/*.js", + "vitest-setup-client.ts", + "eslint.config.js", + "./.svelte-kit/ambient.d.ts", + "./.svelte-kit/non-ambient.d.ts", + "./.svelte-kit/types/**/$types.d.ts", + "./src/**/*.js", + "./src/**/*.ts", + "./src/**/*.svelte", + "./tests/**/*.js", + "./tests/**/*.ts", + "./tests/**/*.svelte", + "./vite.config.js", + "./vite.config.ts" + ] + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // To make changes to top-level options such as include and exclude, we recommend extending + // the generated config; see https://svelte.dev/docs/kit/configuration#typescript +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..3c60fcc --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,71 @@ +import { paraglideVitePlugin } from '@inlang/paraglide-js'; +import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'vitest/config'; +import { sveltekit } from '@sveltejs/kit/vite'; + +/* En CI, por defecto solo Node (evita Chromium). Con JENKINS_VITEST_FULL=1 o VITEST_FULL=1, también @vitest/browser. */ +const inCi = process.env.CI === 'true' || Boolean(process.env.JENKINS_URL); +const vitestFull = + process.env.VITEST_FULL === '1' || process.env.JENKINS_VITEST_FULL === '1'; +const skipBrowser = process.env.VITEST_NO_BROWSER === '1'; +const vitestNoBrowser = (inCi && !vitestFull) || skipBrowser; + +const vitestServerProject = { + extends: './vite.config.ts', + test: { + name: 'server' as const, + environment: 'node' as const, + include: ['src/**/*.{test,spec}.{js,ts}'], + exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'], + }, +}; + +const vitestClientProject = { + extends: './vite.config.ts', + test: { + name: 'client' as const, + environment: 'browser' as const, + browser: { + enabled: true, + provider: 'playwright' as const, + instances: [{ browser: 'chromium' as const }], + }, + include: ['src/**/*.svelte.{test,spec}.{js,ts}'], + exclude: ['src/lib/server/**'], + setupFiles: ['./vitest-setup-client.ts'], + }, +}; + +export default defineConfig({ + server: { + port: 5173, // fija el puerto + host: true, // escucha en 0.0.0.0 + // Lista explícita + peticiones internas (p. ej. chunks JSON `?import`) pueden usar + // Hosts distintos y recibir 403. `true` permite cualquier Host en dev. + allowedHosts: true, + proxy: { + '/api/uploads': { + target: 'http://backend:8000', + changeOrigin: true + }, + '/api/v1/core/help-center': { + target: 'http://backend:8000', + changeOrigin: true + } + } + }, + plugins: [ + tailwindcss(), + sveltekit(), + paraglideVitePlugin({ + project: './project.inlang', + outdir: './src/lib/paraglide' + }) + ], + test: { + expect: { requireAssertions: true }, + projects: vitestNoBrowser + ? [vitestServerProject] + : [vitestClientProject, vitestServerProject] + } +}); diff --git a/frontend/vitest-setup-client.ts b/frontend/vitest-setup-client.ts new file mode 100644 index 0000000..570b9f0 --- /dev/null +++ b/frontend/vitest-setup-client.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/scripts/auth-mode.sh b/scripts/auth-mode.sh new file mode 100644 index 0000000..ee1e954 --- /dev/null +++ b/scripts/auth-mode.sh @@ -0,0 +1,214 @@ +#!/bin/bash +# Alterna entre auth local (sin Keycloak/Hub) y auth workspace. +# +# Uso: +# ./auth-mode.sh → muestra modo actual +# ./auth-mode.sh local → activa login local sin workspace +# ./auth-mode.sh workspace → configura y activa conexión con workspace + +set -e + +# Siempre operar sobre el .env en la raíz del proyecto (un nivel arriba de scripts/) +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +cd "$PROJECT_ROOT" + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +RED='\033[0;31m' +BOLD='\033[1m' +NC='\033[0m' + +ENV_FILE=".env" + +# ── Helpers ──────────────────────────────────────────────────────────────────── + +get_env() { + grep -E "^${1}=" "$ENV_FILE" 2>/dev/null | cut -d'=' -f2- | tr -d '"' | tr -d "'" +} + +set_env() { + local key="$1" value="$2" + if grep -qE "^${key}=" "$ENV_FILE" 2>/dev/null; then + sed -i "s|^${key}=.*|${key}=${value}|" "$ENV_FILE" + else + echo "${key}=${value}" >> "$ENV_FILE" + fi +} + +ask() { + # ask "Pregunta" "default" → imprime en stderr, devuelve valor por stdout + local prompt="$1" default="$2" answer + echo -en "${CYAN} ${prompt}${default:+ [${default}]}: ${NC}" >&2 + read -r answer + echo "${answer:-$default}" +} + +restart_services() { + echo "" + echo -en "${YELLOW} ¿Reiniciar backend y frontend ahora? [S/n]: ${NC}" + read -r ans + if [[ "${ans,,}" != "n" ]]; then + echo -e "${BLUE} Reiniciando servicios...${NC}" + docker compose up -d --force-recreate backend frontend + echo -e "${GREEN} ✓ Listo — espera unos segundos a que levanten.${NC}" + else + echo -e "${YELLOW} Reinicia manualmente cuando estés listo:${NC}" + echo -e " ${BLUE}docker compose up -d --force-recreate backend frontend${NC}" + fi +} + +# check_url "Etiqueta" "url" → HEAD/GET con timeout corto; reporta OK / código / sin respuesta +check_url() { + local label="$1" url="$2" + if [ -z "$url" ]; then + echo -e " ${YELLOW}∅${NC} ${label}: sin URL configurada" + return + fi + local code + code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 --max-time 8 "$url" 2>/dev/null || echo "000") + if [ "$code" = "000" ]; then + echo -e " ${RED}✗${NC} ${label}: sin respuesta (${url})" + elif [ "$code" -ge 200 ] && [ "$code" -lt 500 ]; then + echo -e " ${GREEN}✓${NC} ${label}: responde (HTTP ${code})" + else + echo -e " ${YELLOW}!${NC} ${label}: HTTP ${code} (${url})" + fi +} + +# Valida conectividad al Hub y Keycloak desde la máquina host. +validate_workspace_connection() { + local hub="$1" kc="$2" realm="$3" + echo "" + echo -e "${BOLD} Validando conexión al workspace...${NC}" + echo -e "${BLUE} ──────────────────────────────────────${NC}" + + # Hub: probar la raíz pública + check_url "Hub" "${hub%/}" + + # Keycloak: el endpoint OIDC well-known del realm confirma que el realm existe + local realm_name="${realm:-master}" + check_url "Keycloak (realm ${realm_name})" "${kc%/}/realms/${realm_name}/.well-known/openid-configuration" + + echo -e "${BLUE} ──────────────────────────────────────${NC}" + echo -e " ${YELLOW}Recordatorio:${NC} validar conexión no equivale a estar registrado." + echo -e " Para entrar desde el workspace, el equipo del Hub debe:" + echo -e " • Registrar esta app en el Hub (App Launcher)." + echo -e " • Agregar tu redirect URI en el client de Keycloak:" + echo -e " ${BLUE}/auth/callback${NC}" +} + +# ── Crear .env si no existe ──────────────────────────────────────────────────── + +if [ ! -f "$ENV_FILE" ]; then + [ -f ".env.example" ] && cp .env.example "$ENV_FILE" || touch "$ENV_FILE" + echo -e "${GREEN}✓ .env creado${NC}" +fi + +# ── Estado actual ────────────────────────────────────────────────────────────── + +current_local=$(get_env "DEV_LOCAL_AUTH") +is_local=$([[ "${current_local,,}" == "true" ]] && echo "yes" || echo "no") + +show_status() { + echo "" + if [ "$is_local" = "yes" ]; then + echo -e " ${BOLD}Modo:${NC} ${YELLOW}LOCAL${NC} (sin Keycloak/Hub)" + echo -e " ${BOLD}Acceso:${NC} ${BLUE}http://localhost:5173/login${NC} → 'Entrar como dev'" + else + local ws=$(get_env "WORKSPACE_URL") + local client=$(get_env "KEYCLOAK_CLIENT_ID") + echo -e " ${BOLD}Modo:${NC} ${GREEN}WORKSPACE${NC} (Keycloak + Hub)" + echo -e " ${BOLD}Workspace:${NC} ${ws:-no configurado}" + echo -e " ${BOLD}Keycloak:${NC} ${ws:+${ws}/kcauth} (realm $(get_env KEYCLOAK_REALM), client ${client:-?})" + fi + echo "" +} + +# ── Comando ──────────────────────────────────────────────────────────────────── + +MODE="${1:-status}" + +case "$MODE" in + +# ── LOCAL ────────────────────────────────────────────────────────────────────── + local) + echo -e "\n${BOLD}Activando modo LOCAL${NC}" + echo -e "${YELLOW}──────────────────────────────────────────${NC}" + + set_env "DEV_LOCAL_AUTH" "True" + is_local="yes" + + # SECRET_KEY — necesario para firmar el JWT local + current_key=$(get_env "SECRET_KEY") + if [ -z "$current_key" ] || [ "$current_key" = "change-this-secret-key-in-production" ]; then + secret=$(openssl rand -hex 32 2>/dev/null || echo "dev-$(date +%s | md5sum | head -c 32)") + set_env "SECRET_KEY" "$secret" + echo -e " ${GREEN}✓${NC} SECRET_KEY generado" + fi + + # BACKEND_URL — URL interna del backend para que el frontend llame al dev-login + current_backend=$(get_env "BACKEND_URL") + if [ -z "$current_backend" ]; then + set_env "BACKEND_URL" "http://backend:8000" + echo -e " ${GREEN}✓${NC} BACKEND_URL=http://backend:8000" + fi + + echo -e " ${GREEN}✓${NC} DEV_LOCAL_AUTH=True" + show_status + restart_services + ;; + +# ── WORKSPACE ───────────────────────────────────────────────────────────────── + workspace) + echo -e "\n${BOLD}Configurando modo WORKSPACE${NC}" + echo -e "${BLUE}──────────────────────────────────────────${NC}" + echo -e " Hub y Keycloak se derivan de WORKSPACE_URL." + echo -e " Presiona Enter para mantener el valor actual.\n" + + # Las dos únicas preguntas necesarias: + current_ws=$(get_env "WORKSPACE_URL") + ws_url=$(ask "URL del workspace" "${current_ws:-https://workspace.aduanasoft.com}") + ws_url="${ws_url%/}" + + current_realm=$(get_env "KEYCLOAK_REALM") + kc_realm=$(ask "Keycloak Realm" "${current_realm:-master}") + + current_client=$(get_env "KEYCLOAK_CLIENT_ID") + kc_client=$(ask "Keycloak Client ID" "${current_client:-app-frontend}") + + echo "" + echo -e "${BLUE} Aplicando configuración...${NC}" + + set_env "WORKSPACE_URL" "$ws_url" + set_env "KEYCLOAK_REALM" "$kc_realm" + set_env "KEYCLOAK_CLIENT_ID" "$kc_client" + set_env "DEV_LOCAL_AUTH" "False" + is_local="no" + + echo -e " ${GREEN}✓${NC} Configuración guardada en .env" + + # Validar conectividad (Hub = WORKSPACE_URL, Keycloak = WORKSPACE_URL/kcauth) + validate_workspace_connection "$ws_url" "${ws_url}/kcauth" "$kc_realm" + + show_status + restart_services + ;; + +# ── STATUS ───────────────────────────────────────────────────────────────────── + status|"") + echo -e "${BOLD}[auth-mode] Estado actual${NC}" + show_status + echo "Comandos:" + echo -e " ${GREEN}./auth-mode.sh local${NC} → Login local, sin workspace" + echo -e " ${GREEN}./auth-mode.sh workspace${NC} → Configurar y conectar al workspace" + echo "" + ;; + + *) + echo -e "${RED}Uso: $0 [local|workspace|status]${NC}" + exit 1 + ;; +esac