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