Files
plantillas-proyectos/backend/api/v1/modules/a76/factura_cove/tasks.py
2026-04-30 16:26:14 -06:00

408 lines
16 KiB
Python

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: <eDocumento>, <e-document>, <edocument>, <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ó <contieneError>true</>
- edocumento: el número de COVE asignado por VUCEM (<eDocument>) 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 <eDocument> (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 <leyenda> como mensaje general o <mensaje>/<descripcionError> 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()