Files
plantillas-proyectos/backend/api/v1/modules/a76/factura_cove/tasks.py

277 lines
11 KiB
Python

from __future__ import annotations
import logging
import time
from typing import Any, Dict
from celery import Task
from core.celery_app import celery_app
from core.database import CoreSessionLocal
from core.exceptions import ValidationException
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceComplianceMx
from .service import FacturaCoveDomainService
from .schemas import GenerateCoveResult
from .external_service import CoveExternalService, CoveExternalResult
logger = logging.getLogger(__name__)
def _progress(task: Task, current: int, status: str) -> None:
task.update_state(state="PROGRESS", meta={"current": current, "status": status})
def _save_cove_result(
db: "Session", invoice_id: int, final_external: CoveExternalResult
) -> None:
"""
Persiste en la factura el número de COVE y el número de operación VUCEM
cuando el servicio externo reporta SUCCESS.
"""
if final_external.status != "success":
return
if not final_external.cove_number and not final_external.vucem_operation_num:
return
invoice = db.get(InvoiceHeader, invoice_id)
if not invoice:
logger.error("No se encontró la factura %s para guardar COVE", invoice_id)
return
compliance = invoice.compliance_mx
if not compliance:
# Creamos un registro mínimo de compliance ligado a la factura.
compliance = InvoiceComplianceMx(
invoice_id=invoice.id,
tenant_id=invoice.tenant_id,
company_id=invoice.company_id,
)
db.add(compliance)
# Idempotencia básica: solo sobrescribir si está vacío o coincide.
if final_external.cove_number:
current_cove = compliance.edocument or ""
new_cove = final_external.cove_number or ""
if not current_cove or current_cove == new_cove:
compliance.edocument = new_cove
if final_external.vucem_operation_num:
current_op = compliance.vucem_operation_num or ""
new_op = final_external.vucem_operation_num or ""
if not current_op or current_op == new_op:
compliance.vucem_operation_num = new_op
db.commit()
def _poll_external_status(
task: Task, external: CoveExternalService, external_task_id: str, timeout_seconds: int = 300
) -> CoveExternalResult:
"""
Realiza polling al endpoint externo de status de COVE hasta obtener un estado final
o agotar el timeout.
"""
start = time.time()
last_payload: Dict[str, Any] = {}
while True:
if time.time() - start > timeout_seconds:
logger.error("Timeout consultando estado de COVE para external_task_id=%s", external_task_id)
return CoveExternalResult(
status="error",
message="Timeout consultando estado de COVE en Ventanilla Única.",
cove_number=None,
vucem_operation_num=None,
raw_response={"last_status": last_payload, "external_task_id": external_task_id},
)
try:
status_payload = external.get_status(external_task_id)
except Exception as exc: # pragma: no cover - errores HTTP inesperados
logger.exception("Error consultando estado externo de COVE")
return CoveExternalResult(
status="error",
message=f"Error consultando estado de COVE en Ventanilla Única: {exc}",
cove_number=None,
vucem_operation_num=None,
raw_response={"last_status": last_payload, "external_task_id": external_task_id},
)
last_payload = status_payload or {}
state = str(last_payload.get("state") or "").upper()
progress = last_payload.get("progress") or {}
# En el ejemplo: progress.progress (float 0-100), progress.current_step (texto), numero_operacion
try:
percent = float(progress.get("progress", 0.0))
except (TypeError, ValueError):
percent = 0.0
current_step = progress.get("current_step") or "Consultando estado de COVE en Ventanilla Única..."
numero_operacion = progress.get("numero_operacion") or last_payload.get("numero_operacion")
if numero_operacion:
current_step = f"{current_step} (Operación: {numero_operacion})"
# Actualizar progreso para que el frontend lo vea en el diálogo
_progress(task, int(percent), str(current_step))
# Estados intermedios: seguimos pollendo
if state in {"PENDING", "STARTED", "PROGRESS"} or not state:
time.sleep(5)
continue
# Estado final: SUCCESS / FAILURE u otros
result_payload = last_payload.get("result") or {}
error_text = last_payload.get("error")
if state in {"SUCCESS", "COMPLETED"}:
cove_number = result_payload.get("cove_number") or result_payload.get("cove")
vucem_operation_num = result_payload.get("vucem_operation_num") or result_payload.get(
"numero_operacion"
)
message = result_payload.get("message") or last_payload.get("message") or "COVE generado correctamente."
return CoveExternalResult(
status="success",
message=message,
cove_number=cove_number,
vucem_operation_num=vucem_operation_num,
raw_response={**last_payload, "external_task_id": external_task_id},
)
# Cualquier otro estado lo tratamos como error
message = error_text or result_payload.get("message") or last_payload.get("message") or state
return CoveExternalResult(
status="error",
message=str(message),
cove_number=None,
vucem_operation_num=None,
raw_response={**last_payload, "external_task_id": external_task_id},
)
@celery_app.task(bind=True, name="factura_cove_generate")
def factura_cove_generate(
self: Task,
invoice_id: int,
tenant_id: int,
company_id: int,
recipient_email: str | None = None,
) -> dict:
"""
Tarea Celery para preparar (y en el futuro generar) un COVE a partir de una factura.
Actualmente:
- Valida prerrequisitos de factura y configuración VU.
- Construye el payload FacturaCoveRequest (sin llamar aún al webservice externo).
- Devuelve un resultado estándar indicando éxito o errores de validación.
En el futuro se puede extender para:
- Invocar al servicio externo de COVE.
- Persistir número de COVE / operación VUCEM en la factura.
"""
db = CoreSessionLocal()
try:
_progress(self, 5, "Validando factura para COVE...")
service = FacturaCoveDomainService(db)
# Esta llamada valida todo y construye el payload; si algo falla, lanza ValidationException
request_payload = service.build_factura_cove_request(
invoice_id=invoice_id,
tenant_id=tenant_id,
company_id=company_id,
recipient_email=recipient_email,
)
_progress(self, 80, "Enviando solicitud al servicio COVE...")
# Integración externa que encola la generación de COVE en Ventanilla Única
external = CoveExternalService()
external_result = external.generate_cove(request_payload)
# Si el servicio externo devolvió un error inmediato (por ejemplo 422),
# devolvemos ese resultado tal cual sin hacer polling adicional.
if external_result.status in {"error", "validation_error"}:
result = GenerateCoveResult(
status=external_result.status,
message=external_result.message,
invoice_id=invoice_id,
cove_number=external_result.cove_number,
vucem_operation_num=external_result.vucem_operation_num,
external_task_id=(
external_result.raw_response.get("task_id") if external_result.raw_response else None
),
external_response=external_result.raw_response,
errors=None,
)
return result.model_dump()
external_task_id = (
external_result.raw_response.get("task_id") if external_result.raw_response else None
)
# Si la factura quedó encolada en VU y tenemos un task_id externo, hacemos polling
# al endpoint de status para acompañar el progreso completo hasta obtener COVE.
if external_task_id and external_result.status in {"external_queued", "queued", "success"}:
_progress(
self,
85,
"Factura enviada a Ventanilla Única, consultando estado de COVE...",
)
final_external = _poll_external_status(self, external, external_task_id)
else:
# Fallback: usamos el resultado tal cual devolvió el endpoint de generación
final_external = external_result
# Intentar persistir COVE / número de operación en la factura cuando sea éxito.
try:
_save_cove_result(db, invoice_id, final_external)
except Exception:
# No fallamos la tarea por errores de persistencia; solo los registramos.
logger.exception("Error guardando COVE en la factura %s", invoice_id)
_progress(self, 100, "Proceso de COVE finalizado.")
result = GenerateCoveResult(
status=final_external.status,
message=final_external.message,
invoice_id=invoice_id,
cove_number=final_external.cove_number,
vucem_operation_num=final_external.vucem_operation_num,
external_task_id=(
final_external.raw_response.get("external_task_id")
or final_external.raw_response.get("task_id")
if final_external.raw_response
else None
),
external_response=final_external.raw_response,
errors=None,
)
return result.model_dump()
except ValidationException as exc:
db.rollback()
logger.info("Validation error in factura_cove_generate: %s", exc.message)
result = GenerateCoveResult(
status="validation_error",
message=exc.message,
invoice_id=invoice_id,
errors=exc.errors,
)
return result.model_dump()
except Exception as exc: # pragma: no cover - errores inesperados de runtime
db.rollback()
logger.exception("Unexpected error in factura_cove_generate")
result = GenerateCoveResult(
status="error",
message=str(exc),
invoice_id=invoice_id,
errors=None,
)
return result.model_dump()
finally:
db.close()