- 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
248 lines
9.0 KiB
Python
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}"'},
|
|
)
|
|
|