Implementacion de celery y problemas con relacioines

This commit is contained in:
2026-01-13 11:27:58 -06:00
parent ce37aa77b2
commit 6214973eaf
8 changed files with 156 additions and 139 deletions

View File

@@ -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)

View File

@@ -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")
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"}

View File

@@ -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__)

View File

@@ -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",

View File

@@ -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):

View File

@@ -260,6 +260,9 @@ services:
depends_on:
- backend
- valkey
networks:
- backend-net
valkey:
image: valkey/valkey:7.2
container_name: a76_valkey

View File

@@ -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();
}
};

View File

@@ -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);