408 lines
16 KiB
Python
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()
|
|
|