From 6214973eafaa01d2e884783d0b1f57a65e0b0821 Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Tue, 13 Jan 2026 11:27:58 -0600 Subject: [PATCH] Implementacion de celery y problemas con relacioines --- .../importacion/facturas/mex/service.py | 11 +-- .../reports/importacion/facturas/routes.py | 66 ++++++------- .../a76/reports/importacion/facturas/task.py | 1 + backend/core/celery_app.py | 3 +- backend/core/middleware.py | 67 ++++--------- docker-compose.yml | 3 + .../dashboard/a76/reports/reports-invoices.ts | 46 +++++---- .../routes/dashboard/invoices/+page.svelte | 98 ++++++++++++++----- 8 files changed, 156 insertions(+), 139 deletions(-) diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py index 1e10d5d2..e07807f8 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py @@ -152,8 +152,7 @@ class FacturaImportacionMexService: num_parte_final = part_master.part_number fraccion_raw = part_master.fraction if part_master.fraction else "" - # --- LÓGICA DE FRACCIÓN DESDE BASE DE DATOS --- - # Limpiar la fracción de la BD (quitar puntos y asegurar 8 dígitos) + fraccion_limpia = fraccion_raw.replace(".", "").strip() if fraccion_limpia: fraccion_limpia = fraccion_limpia[:8].zfill(8) @@ -161,9 +160,7 @@ class FacturaImportacionMexService: # Consultar tabla tariff_fractions fraccion_db = db.query(TariffFraction).filter(TariffFraction.code == fraccion_limpia).first() - # Reglas de negocio solicitadas: - # 1. Preferencia default: "General" - # 2. AdValorem default: "0%" si no existe o es vacío + preferencia_txt = "General" advalorem_txt = "0%" fraccion_imprimir = fraccion_raw @@ -178,7 +175,7 @@ class FacturaImportacionMexService: fraccion_imprimir = fraccion_db.fraction or fraccion_raw else: - # Si no existe en la tabla, aplicamos el fallback visual de puntos + fraccion_imprimir = self._format_fraccion_fallback(fraccion_limpia) partidas_list.append(PartidaSchema( @@ -220,7 +217,7 @@ class FacturaImportacionMexService: clave_comun = max(set(claves), key=claves.count) if claves else "" if bultos > 1 and clave_comun and not clave_comun.endswith("S"): clave_comun += "S" tc = float(tipo_cambio) if tipo_cambio else 1.0 - return TotalesSchema( + return TotalesSchema( cantidad_total=self.formatear_numero(cant), bultos_total=bultos, clave_bultos=clave_comun, peso_neto_total=self.formatear_numero(peso_n), peso_bruto_total=self.formatear_numero(peso_b), valor_total_total=self.formatear_numero(valor), valor_total_dolares=self.formatear_numero(valor / tc if tc > 0 else 0) diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/routes.py b/backend/api/v1/modules/a76/reports/importacion/facturas/routes.py index 0049204e..4feb8f8f 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/routes.py +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/routes.py @@ -2,52 +2,44 @@ from enum import Enum from typing import Dict, Any from fastapi import APIRouter, Depends, Query, Response, HTTPException from sqlalchemy.orm import Session - +from celery.result import AsyncResult +from core.celery_app import celery_app from core.database import get_core_db from core.security import get_current_user, validate_access_to_resource from .mex.service import FacturaImportacionMexService +from .task import generar_pdf_factura_async router = APIRouter() servicio_mex = FacturaImportacionMexService() -class TipoFactura(str, Enum): - mexicana = "mex" +@router.get("/tasks/{task_id}") +async def get_task_status( + task_id: str, + current_user: Dict[str, Any] = Depends(get_current_user), + db: Session = Depends(get_core_db) +): + task_result = AsyncResult(task_id, app=celery_app) -class Formato(str, Enum): - html = "html" - pdf = "pdf" + response = { + "task_id": task_id, + "state": task_result.state, + "result": None + } -@router.get("/{invoice_id}/download") -async def descargar_factura( + if task_result.state == 'FAILURE': + response["result"] = str(task_result.result) + elif task_result.state == 'SUCCESS': + response["result"] = task_result.result + + return response + +@router.post("/{invoice_id}/download-async") +async def trigger_descarga_factura( invoice_id: int, company_id: int = Query(..., description="ID de la empresa"), - tipo: TipoFactura = Query(TipoFactura.mexicana), - formato: Formato = Query(Formato.pdf), - db: Session = Depends(get_core_db), - current_user: Dict[str, Any] = Depends(get_current_user) + current_user: Dict[str, Any] = Depends(get_current_user), + db: Session = Depends(get_core_db) ): - # Validar que el usuario tiene acceso a esta empresa - tenant_id = validate_access_to_resource(db, company_id, current_user) - - if tipo == TipoFactura.mexicana: - try: - # Pasamos invoice_id Y company_id al servicio - contenido, nombre, media_type = servicio_mex.generar_factura_completa( - db=db, - invoice_id=invoice_id, - company_id=company_id, - formato=formato.value - ) - - return Response( - content=contenido, - media_type=media_type, - headers={ - "Content-Disposition": f"attachment; filename={nombre}", - "Access-Control-Expose-Headers": "Content-Disposition" - } - ) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - else: - raise HTTPException(status_code=501, detail="Tipo no implementado") \ No newline at end of file + validate_access_to_resource(db, company_id, current_user) + task = generar_pdf_factura_async.delay(invoice_id, company_id) + return {"task_id": task.id, "message": "Generación iniciada"} \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/task.py b/backend/api/v1/modules/a76/reports/importacion/facturas/task.py index d16988bb..1b859965 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/task.py +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/task.py @@ -2,6 +2,7 @@ import base64 import logging from core.celery_app import celery_app from core.database import CoreSessionLocal + from .mex.service import FacturaImportacionMexService logger = logging.getLogger(__name__) diff --git a/backend/core/celery_app.py b/backend/core/celery_app.py index 520248ce..8b95558f 100644 --- a/backend/core/celery_app.py +++ b/backend/core/celery_app.py @@ -1,7 +1,8 @@ import os from celery import Celery -valkey_url = os.getenv("VALKEY_URL", "redis://localhost:6379/0") + +valkey_url = os.getenv("VALKEY_URL", "redis://valkey:6379/0") celery_app = Celery( "anexo76_tasks", diff --git a/backend/core/middleware.py b/backend/core/middleware.py index f5d0df8a..695fd79c 100644 --- a/backend/core/middleware.py +++ b/backend/core/middleware.py @@ -1,83 +1,54 @@ -""" -Middleware personalizado para Anexo76 -- Validación de licencias -- Gestión de multi-tenancy -- Logging de requests -""" - import logging import time from typing import Callable - -from fastapi import HTTPException, Request +from fastapi import HTTPException, Request, Response from starlette.middleware.base import BaseHTTPMiddleware - from .config import settings from .database import CoreSessionLocal from .security import get_tenant_from_token, verify_token logger = logging.getLogger(__name__) - class TenantMiddleware(BaseHTTPMiddleware): - """ - Middleware para identificar y validar el tenant en cada request - """ - async def dispatch(self, request: Request, call_next: Callable): - # Rutas públicas que no requieren tenant - # Permitir acceso sin autenticación a rutas de documentación y salud - doc_prefixes = ["/api/redoc", "/api/openapi.json"] + # No validamos token, no buscamos tenant. + # Esto permite que CORSMiddleware haga su trabajo. + if request.method == "OPTIONS": + return await call_next(request) + + # 2. Definición de rutas (tal cual las tenías) + doc_prefixes = ["/api/redoc", "/api/openapi.json", "/api/docs"] public_prefixes = ["/api/v1/auth", "/api/v1/status", "/api/health", "/api/"] - path = request.url.path - # Permitir cualquier subruta de docs/redoc/openapi.json (por ejemplo, /api/docs, /api/docs/, /api/docs/oauth2-redirect) - if any( - path == prefix or path.startswith(prefix + "/") for prefix in doc_prefixes - ): + + # 3. Bypass para rutas públicas y docs + if any(path == prefix or path.startswith(prefix + "/") for prefix in doc_prefixes): return await call_next(request) - # Permitir rutas públicas exactas o con prefijo - if any( - path == prefix or (prefix != "/" and path.startswith(prefix)) - for prefix in public_prefixes - ): + + if any(path == prefix or (prefix != "/" and path.startswith(prefix)) for prefix in public_prefixes): return await call_next(request) - # Extraer token y obtener tenant + # 4. Validación estricta de Token (solo para lo que no es público ni OPTIONS) auth_header = request.headers.get("Authorization") - if not auth_header or not auth_header.startswith("Bearer "): raise HTTPException( - status_code=401, detail="Missing or invalid authorization header" + status_code=401, + detail="Missing or invalid authorization header" ) token = auth_header.split(" ")[1] - try: user_info = verify_token(token) tenant_id = get_tenant_from_token(user_info) - - # ⚠️ NOTA: tenant_id puede ser None para usuarios SSO que aún no tienen tenant asignado - # En ese caso, el endpoint específico deberá manejarlo - if not tenant_id: - logger.warning( - f"⚠️ Token sin tenant_id para usuario: {user_info.get('sub', 'unknown')}" - ) - # No lanzamos error aquí, dejamos que el endpoint decida qué hacer - - # Agregar tenant_id al state del request (puede ser None) + request.state.tenant_id = tenant_id request.state.user_info = user_info - - except HTTPException: - # Re-lanzar HTTPException directamente - raise except Exception as e: logger.error(f"❌ Tenant validation error: {str(e)}") raise HTTPException(status_code=401, detail="Invalid authentication") - response = await call_next(request) - return response + # 5. Continuar con la petición real + return await call_next(request) class LicenseValidationMiddleware(BaseHTTPMiddleware): diff --git a/docker-compose.yml b/docker-compose.yml index bbca1665..c3425493 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -260,6 +260,9 @@ services: depends_on: - backend - valkey + networks: + - backend-net + valkey: image: valkey/valkey:7.2 container_name: a76_valkey diff --git a/frontend/src/lib/api/dashboard/a76/reports/reports-invoices.ts b/frontend/src/lib/api/dashboard/a76/reports/reports-invoices.ts index 68ebafab..33c619f7 100644 --- a/frontend/src/lib/api/dashboard/a76/reports/reports-invoices.ts +++ b/frontend/src/lib/api/dashboard/a76/reports/reports-invoices.ts @@ -1,37 +1,35 @@ -// En invoices.ts -// 1. Recuperamos la URL base del entorno (así funciona igual en local y en producción) -// Nota: El nombre 'VITE_API_URL' depende de cómo lo tengan en tu proyecto. -// A veces es import.meta.env.PUBLIC_API_URL const BASE_URL = import.meta.env.VITE_API_URL || ''; export const invoicesReportsApi = { - // ... tus otros métodos ... - - downloadPdf: async (invoiceId: number, type: 'mex' | 'usa' = 'mex', companyId: number) => { - const params = new URLSearchParams({ - tipo: type, - formato: 'pdf', - company_id: companyId.toString() - }); - // 2. Usamos la variable, no el texto fijo - const endpoint = `${BASE_URL}/v1/a76/reports/importacion/facturas/${invoiceId}/download?${params.toString()}`; - - const token = localStorage.getItem('access_token'); + + triggerPdfGeneration: async (invoiceId: number, companyId: number) => { + const params = new URLSearchParams({ company_id: companyId.toString() }); + const endpoint = `${BASE_URL}/v1/a76/reports/importacion/facturas/${invoiceId}/download-async?${params.toString()}`; + const token = localStorage.getItem('access_token'); const response = await fetch(endpoint, { - method: 'GET', + method: 'POST', headers: { 'Authorization': `Bearer ${token}`, - + 'Content-Type': 'application/json' } }); - if (!response.ok) { - const error = await response.json().catch(() => ({ detail: 'Error desconocido' })); - throw new Error(error.detail || 'Error al descargar'); - } - - return response.blob(); + if (!response.ok) throw new Error('Error al iniciar la generación'); + return await response.json(); }, + + getTaskStatus: async (taskId: string) => { + const endpoint = `${BASE_URL}/v1/a76/reports/importacion/facturas/tasks/${taskId}`; + + const token = localStorage.getItem('access_token'); + const response = await fetch(endpoint, { + method: 'GET', + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (!response.ok) throw new Error('Error al consultar estado'); + return await response.json(); + } }; \ No newline at end of file diff --git a/frontend/src/routes/dashboard/invoices/+page.svelte b/frontend/src/routes/dashboard/invoices/+page.svelte index dc8f2d53..51212163 100644 --- a/frontend/src/routes/dashboard/invoices/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/+page.svelte @@ -312,29 +312,83 @@ } } - // --- NUEVA FUNCIÓN: DESCARGAR PDF --- - async function handleDownloadPdf(invoice: any) { - const toastId = toast.loading("Generando PDF..."); - try { - // Llamada limpia - const blob = await invoicesReportsApi.downloadPdf(invoice.id, 'mex', companyStore.activeCompany.id); - - // Lógica de descarga (crear el link fantasma) - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `Factura_${invoice.invoice_number}.pdf`; - document.body.appendChild(a); - a.click(); - window.URL.revokeObjectURL(url); - document.body.removeChild(a); - - toast.success("Descargado", { id: toastId }); - } catch (error) { - console.error(error); - toast.error("Error al descargar", { id: toastId }); - } + // Utilidad para convertir Base64 a Blob +function base64ToBlob(base64: string, type: string) { + const binStr = atob(base64); + const len = binStr.length; + const arr = new Uint8Array(len); + for (let i = 0; i < len; i++) { + arr[i] = binStr.charCodeAt(i); } + return new Blob([arr], { type: type }); +} + +async function handleDownloadPdf(invoice: any) { + const toastId = toast.loading("Iniciando generación de PDF..."); + + try { + // 1. Trigger: Iniciar la tarea en Celery + const { task_id } = await invoicesReportsApi.triggerPdfGeneration( + invoice.id, + companyStore.activeCompany.id + ); + + toast.loading("Procesando PDF en segundo plano...", { id: toastId }); + + // 2. Polling: Loop para verificar estado + let intentos = 0; + const maxIntentos = 30; // Timeout de seguridad (aprox 60 segs) + + const interval = setInterval(async () => { + intentos++; + try { + const statusData = await invoicesReportsApi.getTaskStatus(task_id); + + if (statusData.state === 'SUCCESS') { + clearInterval(interval); + + const result = statusData.result; // Tu dict del backend + + if (result.status === 'success') { + // 3. Convertir Base64 a Blob y Descargar + const blob = base64ToBlob(result.content, result.media_type); + + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = result.file_name; // Nombre que viene del worker + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + + toast.success("PDF Descargado", { id: toastId }); + } else { + toast.error("Error al generar el archivo", { id: toastId }); + } + } + else if (statusData.state === 'FAILURE') { + clearInterval(interval); + toast.error("Falló la generación del PDF", { id: toastId }); + } + else if (intentos >= maxIntentos) { + clearInterval(interval); + toast.error("Tiempo de espera agotado", { id: toastId }); + } + // Si es PENDING o STARTED, el intervalo continúa... + + } catch (err) { + console.error(err); + clearInterval(interval); // Detener en caso de error de red + toast.error("Error de conexión", { id: toastId }); + } + }, 2000); // Consultar cada 2 segundos + + } catch (error) { + console.error(error); + toast.error("No se pudo iniciar la descarga", { id: toastId }); + } +} function handleCreateClick() { const params = new URLSearchParams(window.location.search);