Files
plantillas-proyectos/backend/api/v1/modules/a76/factura_cove/routes.py
Galindo97 125405458c feat(auth): add hub_admin global access support across modules
- Add is_hub_admin() and resolve_tenant_id_required() to core/security
- hub_admin resolves tenant from company or uses None as global sentinel
- Update routes and services to skip tenant filter when tenant_id is None
- UserService accepts is_hub_admin flag for cross-tenant user management
- get_my_companies returns all companies for hub_admin without tenant restriction
2026-05-15 09:22:35 -05:00

248 lines
9.0 KiB
Python

from __future__ import annotations
import io
import logging
from typing import Any, Dict
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from core import storage_s3
from core.celery_app import celery_app
from core.config import settings
from core.database import get_core_db
from core.exceptions import ValidationException
from core.s3_keys import cove_acuse_pdf_key
from core.security import get_current_user, get_tenant_from_token, resolve_tenant_id_required, validate_access_to_resource
from api.v1.modules.core.tasks_tracking import track_and_dispatch
from .schemas import (
CoveEligibilityResponse,
FacturaCoveResponse,
GenerateCoveFromInvoiceRequest,
)
from .service import FacturaCoveDomainService
from .tasks import factura_cove_generate
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post(
"/invoices/{invoice_id}/cove",
response_model=FacturaCoveResponse,
summary="Generar COVE a partir de una factura (asíncrono)",
)
def trigger_cove_for_invoice(
invoice_id: int,
body: GenerateCoveFromInvoiceRequest,
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Dispara la tarea Celery `factura_cove_generate` para una factura específica.
- Valida acceso a la compañía.
- Registra la tarea en el tracker de tareas.
- Retorna el `task_id` para hacer polling de estado desde el frontend.
"""
tenant_id = validate_access_to_resource(db, body.company_id, current_user)
# Validación mínima de existencia/propiedad de la factura (el dominio hará validaciones más profundas).
from api.v1.modules.a76.invoices.models import InvoiceHeader
invoice: InvoiceHeader | None = db.get(InvoiceHeader, invoice_id)
if not invoice:
raise HTTPException(status_code=404, detail=f"Factura {invoice_id} no encontrada.")
if invoice.company_id != body.company_id or invoice.tenant_id != tenant_id:
raise HTTPException(
status_code=400,
detail="La factura no pertenece a la compañía o tenant actuales.",
)
task = track_and_dispatch(
db=db,
task=factura_cove_generate,
tenant_id=tenant_id,
company_id=body.company_id,
requested_by_user=(
current_user.get("email")
or current_user.get("preferred_username")
or current_user.get("username")
or "system"
),
task_name="factura_cove_generate",
task_group="factura_cove",
task_origin="a76/factura_cove/invoices/cove",
args=[invoice_id, int(tenant_id), body.company_id, body.recipient_email],
)
return FacturaCoveResponse(
task_id=task.id,
status="queued",
message="Tarea de validación/generación de COVE encolada.",
)
@router.get(
"/invoices/cove/{task_id}/status",
summary="Estado de tarea de COVE para factura",
)
def get_cove_status(task_id: str) -> Dict[str, Any]:
"""
Consulta el estado de una tarea Celery de generación de COVE.
Retorna:
- state: 'PROCESSING' | 'SUCCESS' | 'FAILURE'
- info: { current: int, status: str } (cuando state == 'PROCESSING')
- result: dict (cuando state == 'SUCCESS' o 'FAILURE')
"""
task_result = celery_app.AsyncResult(task_id)
if task_result.state in ("PENDING", "STARTED"):
return {
"state": "PROCESSING",
"info": {"current": 0, "status": "Iniciando generación de COVE..."},
}
if task_result.state == "PROGRESS":
return {
"state": "PROCESSING",
"info": task_result.info or {"current": 0, "status": "Procesando COVE..."},
}
if task_result.state == "SUCCESS":
return {
"state": "SUCCESS",
"result": task_result.result,
}
error_info = task_result.result
if isinstance(error_info, Exception):
error_msg = str(error_info)
else:
error_msg = str(error_info) if error_info else "Error desconocido"
return {
"state": "FAILURE",
"result": error_msg,
}
@router.get(
"/invoices/{invoice_id}/cove/eligibility",
response_model=CoveEligibilityResponse,
summary="Verifica si una factura puede generar COVE",
)
def check_cove_eligibility(
invoice_id: int,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Evalúa si la factura tiene todos los datos necesarios (VU, factura, partidas)
para poder generar un COVE. No dispara la tarea Celery.
"""
tenant_id = resolve_tenant_id_required(current_user, db=db, company_id=company_id)
service = FacturaCoveDomainService(db)
eligibility = service.check_eligibility(invoice_id=invoice_id, tenant_id=tenant_id, company_id=company_id)
return eligibility
@router.get(
"/invoices/{invoice_id}/cove/acuse",
summary="Generar o descargar el Acuse de COVE en PDF",
responses={
200: {"content": {"application/pdf": {}}, "description": "PDF de Acuse de COVE"},
422: {"description": "La factura aún no tiene XML de COVE asociado"},
404: {"description": "Factura no encontrada"},
},
)
def get_cove_acuse_pdf(
invoice_id: int,
company_id: int = Query(..., description="Company ID"),
force_refresh: bool = Query(False, description="Forzar regeneración del PDF ignorando la caché S3"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Genera el PDF de Acuse de COVE para una factura.
- Construye el contexto del reporte a partir del XML de COVE almacenado
en la configuración VU (agente / empresa).
- Renderiza el PDF con Jinja2 + pdfkit/wkhtmltopdf (mismo patrón que DODA).
- Cachea el PDF en S3 con clave estable; en la siguiente llamada lo reutiliza.
- Usa ``force_refresh=true`` para saltar la caché y regenerar el PDF.
- Devuelve StreamingResponse con Content-Type application/pdf.
Si aún no existe un XML de COVE asociado (consulta a VU no realizada),
el servicio devolverá un error 422.
"""
from api.v1.modules.a76.invoices.models import InvoiceHeader
from .report_service import CoveAcuseReportService
tenant_id = int(validate_access_to_resource(db, company_id, current_user))
invoice: InvoiceHeader | None = db.get(InvoiceHeader, invoice_id)
if not invoice:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Factura {invoice_id} no encontrada.",
)
if invoice.company_id != company_id or invoice.tenant_id != tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="La factura no pertenece a la compañía o tenant actuales.",
)
pdf_key = cove_acuse_pdf_key(tenant_id, company_id, invoice_id)
filename = f"acuse_cove_{invoice.invoice_number or invoice_id}.pdf"
# ── Caché S3: reutilizar si el PDF ya existe y no se fuerza regeneración ─
if not force_refresh and settings.use_s3_object_storage and storage_s3.object_exists(pdf_key):
try:
cached_pdf = storage_s3.get_object_bytes(pdf_key)
if cached_pdf:
return StreamingResponse(
io.BytesIO(cached_pdf),
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="{filename}"'},
)
except Exception:
logger.warning("No se pudo leer el PDF cacheado de S3 para factura %s; regenerando.", invoice_id)
# ── Generar PDF ─────────────────────────────────────────────
try:
svc = CoveAcuseReportService()
pdf_bytes = svc.build_pdf(db, invoice_id, tenant_id, company_id)
except ValidationException as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=exc.message,
)
except Exception as exc:
logger.exception("Error generando PDF de Acuse de COVE para factura %s", invoice_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error al generar el Acuse de COVE: {exc}",
)
# ── Guardar en S3 ───────────────────────────────────────────
if settings.use_s3_object_storage:
try:
storage_s3.put_object_bytes(pdf_key, pdf_bytes, content_type="application/pdf")
except Exception:
logger.warning("No se pudo guardar el PDF de Acuse de COVE en S3 para factura %s.", invoice_id)
return StreamingResponse(
io.BytesIO(pdf_bytes),
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="{filename}"'},
)