Implementacion de celery y problemas con relacioines
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"}
|
||||
@@ -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__)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -260,6 +260,9 @@ services:
|
||||
depends_on:
|
||||
- backend
|
||||
- valkey
|
||||
networks:
|
||||
- backend-net
|
||||
|
||||
valkey:
|
||||
image: valkey/valkey:7.2
|
||||
container_name: a76_valkey
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user