from __future__ import annotations import base64 import logging import time from typing import Any, Dict from xml.etree import ElementTree as ET from celery import Task from core.celery_app import celery_app from core.database import CoreSessionLocal from core.exceptions import ValidationException from core import storage_s3 from core.s3_keys import cove_xml_key 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 _strip_ns(tag: str) -> str: """Elimina el namespace {uri} de un tag XML y lo devuelve en minúsculas.""" if "}" in tag: tag = tag.split("}", 1)[1] return tag.strip().lower() def _extract_edocument_from_xml(xml_bytes: bytes) -> str | None: """ Extrae el número de e-document (COVE) de un XML de VUCEM. Busca los tags más comunes que VUCEM utiliza para el identificador del documento COVE: , , , . """ try: root = ET.fromstring(xml_bytes) except Exception: return None for node in root.iter(): tag = _strip_ns(node.tag) if tag in {"edocumento", "e-document", "edocument", "cove", "numerocove", "numero_cove"}: value = (node.text or "").strip() if value: return value return None def _parse_consulta_respuesta(b64_str: str) -> tuple[bool, str | None, list[str]]: """ Parsea el XML de la respuesta de VUCEM a la consulta de estado (campo ``consulta_respuesta_base64``). Devuelve una tupla (tiene_error, edocumento, mensajes_error) donde: - tiene_error: True si VUCEM reportó true - edocumento: el número de COVE asignado por VUCEM () si no hay error - mensajes_error: lista de textos de error si tiene_error es True """ tiene_error = False edocumento: str | None = None mensajes_error: list[str] = [] if not b64_str: return tiene_error, edocumento, mensajes_error try: xml_bytes = base64.b64decode(b64_str) root = ET.fromstring(xml_bytes) except Exception: logger.warning("No se pudo decodificar/parsear consulta_respuesta_base64", exc_info=True) return tiene_error, edocumento, mensajes_error for node in root.iter(): tag = _strip_ns(node.tag) if tag == "contieneerror": tiene_error = (node.text or "").strip().lower() == "true" elif tag in {"edocument", "edocumento"}: # VUCEM usa (sin 'o') en la respuesta de consulta value = (node.text or "").strip() if value: edocumento = value elif tag in {"mensaje", "leyenda", "descripcionerror", "mensajeerror"}: # VUCEM puede usar como mensaje general o / para errores text = (node.text or "").strip() if text and tiene_error: mensajes_error.append(text) return tiene_error, edocumento, mensajes_error def _save_cove_result( db: "Session", invoice_id: int, final_external: CoveExternalResult ) -> None: """ Persiste en la factura el número de COVE, el número de operación VUCEM y el XML de COVE (en S3) cuando el servicio externo reporta SUCCESS. """ if final_external.status != "success": 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: compliance = InvoiceComplianceMx( invoice_id=invoice.id, tenant_id=invoice.tenant_id, company_id=invoice.company_id, ) db.add(compliance) # Guardar XML de COVE en S3 y extraer e-document si no está en la factura. if final_external.cove_xml_base64: try: xml_bytes = base64.b64decode(final_external.cove_xml_base64) s3_key = cove_xml_key(invoice.tenant_id, invoice.company_id, invoice_id) storage_s3.put_object_bytes(s3_key, xml_bytes, content_type="application/xml") logger.info("XML de COVE guardado en S3: %s", s3_key) # Extraer e-document del XML si la factura aún no lo tiene. if not (compliance.edocument or "").strip(): edoc_from_xml = _extract_edocument_from_xml(xml_bytes) if edoc_from_xml: compliance.edocument = edoc_from_xml logger.info("e-document extraído del XML y guardado: %s", edoc_from_xml) except Exception: logger.exception("Error guardando XML de COVE en S3 para factura %s", invoice_id) # 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"}: vucem_operation_num = result_payload.get("vucem_operation_num") or result_payload.get( "numero_operacion" ) # SOAP request del COVE enviado a VUCEM (para el acuse PDF) cove_xml_base64 = result_payload.get("envio_base64") or result_payload.get("cove_xml_base64") # Extraer el e-document (COVE number) del XML de respuesta de VUCEM. # Solo se usa para capturar el número; la detección de errores sigue # basándose en el campo tiene_errores del VU API. consulta_b64 = result_payload.get("consulta_respuesta_base64", "") or "" _, vucem_edocumento, _ = _parse_consulta_respuesta(consulta_b64) cove_number = ( vucem_edocumento or result_payload.get("cove_number") or result_payload.get("cove") ) tiene_errores = bool(result_payload.get("tiene_errores")) mensajes = result_payload.get("mensajes") first_error_message = None if isinstance(mensajes, list) and mensajes: first_item = mensajes[0] or {} if isinstance(first_item, dict): first_error_message = ( first_item.get("descripcion") or first_item.get("mensaje") or first_item.get("codigo") ) if tiene_errores: message = ( first_error_message or result_payload.get("message") or last_payload.get("message") or "Factura COVE transmitida con errores." ) return CoveExternalResult( status="error", message=str(message), cove_number=cove_number, vucem_operation_num=vucem_operation_num, cove_xml_base64=cove_xml_base64, raw_response={**last_payload, "external_task_id": external_task_id}, ) 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, cove_xml_base64=cove_xml_base64, 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 generar COVE a partir de una factura. Flujo actual: - Valida prerrequisitos de factura y configuración VU. - Construye el payload FacturaCoveRequest. - Invoca el servicio externo de COVE. - Si el externo devuelve task_id, hace polling hasta estado final. - Persiste número de COVE / operación VUCEM cuando aplica. """ 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()