feature/vu-api-acuse-xml-pdf
This commit is contained in:
@@ -136,7 +136,11 @@ class CustomsBrokerVUService:
|
||||
|
||||
if vu:
|
||||
# Update existing
|
||||
for key, value in vu_data.model_dump(exclude_unset=True).items():
|
||||
safe_updates = vu_data.model_dump(
|
||||
exclude_unset=True,
|
||||
exclude={"tenant_id", "company_id"},
|
||||
)
|
||||
for key, value in safe_updates.items():
|
||||
setattr(vu, key, value)
|
||||
db.commit()
|
||||
db.refresh(vu)
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict
|
||||
import logging
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -15,18 +16,14 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
@dataclass
|
||||
class CoveExternalResult:
|
||||
"""
|
||||
Resultado simplificado de la llamada al servicio externo de COVE.
|
||||
|
||||
Por ahora usamos un stub que simula una respuesta exitosa y devuelve
|
||||
un número de COVE ficticio para poder probar el flujo end-to-end
|
||||
(task_id + cove_number) sin depender del ambiente externo.
|
||||
"""
|
||||
"""Resultado normalizado de POST /generar-factura-cove contra el API de VU."""
|
||||
|
||||
status: str
|
||||
message: str | None = None
|
||||
cove_number: str | None = None
|
||||
vucem_operation_num: str | None = None
|
||||
# XML de COVE devuelto por VU codificado en base64 (campo envio_base64 del result)
|
||||
cove_xml_base64: str | None = None
|
||||
raw_response: Dict[str, Any] | None = None
|
||||
|
||||
|
||||
@@ -34,18 +31,9 @@ class CoveExternalService:
|
||||
"""
|
||||
Cliente del API externo de COVE.
|
||||
|
||||
NOTA IMPORTANTE:
|
||||
----------------
|
||||
Esta implementación es, por ahora, un stub que:
|
||||
- No realiza la llamada HTTP real.
|
||||
- Genera un número de COVE ficticio basado en los datos de la factura.
|
||||
|
||||
Cuando se tenga disponible la URL y contrato exacto del servicio COVE,
|
||||
este stub se puede reemplazar por una implementación con httpx/requests
|
||||
que:
|
||||
- Serialice el FacturaCoveRequest al JSON requerido.
|
||||
- Realice la petición HTTP.
|
||||
- Mapee la respuesta real a CoveExternalResult.
|
||||
Implementación real:
|
||||
- POST /api/v1/factura-cove/generar-factura-cove para iniciar proceso.
|
||||
- GET /api/v1/factura-cove/status/{task_id} para consultar estado.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
@@ -71,8 +59,12 @@ class CoveExternalService:
|
||||
len(configuracion_vu.get("archivo_key_base64") or ""),
|
||||
)
|
||||
|
||||
with httpx.Client(timeout=30.0, verify=self.verify_ssl) as client:
|
||||
with httpx.Client(timeout=30.0, verify=self.verify_ssl, follow_redirects=False) as client:
|
||||
resp = client.post(url, json=json_payload)
|
||||
if resp.status_code in {301, 302, 307, 308} and resp.headers.get("location"):
|
||||
redirected_url = urljoin(url, resp.headers["location"])
|
||||
logger.info("COVE generate redirect: %s -> %s", url, redirected_url)
|
||||
resp = client.post(redirected_url, json=json_payload)
|
||||
|
||||
# Intentar parsear JSON siempre, incluso en errores 4xx/5xx
|
||||
try:
|
||||
@@ -87,7 +79,7 @@ class CoveExternalService:
|
||||
# Intentar construir un mensaje amigable
|
||||
message = None
|
||||
if isinstance(data, dict):
|
||||
message = data.get("message")
|
||||
message = data.get("message") or data.get("mensaje")
|
||||
if not message and "detail" in data:
|
||||
# FastAPI ValidationError-style: detail: [{loc, msg, type}, ...]
|
||||
try:
|
||||
@@ -95,6 +87,20 @@ class CoveExternalService:
|
||||
message = "; ".join([p for p in parts if p])
|
||||
except Exception:
|
||||
pass
|
||||
if not message and isinstance(data.get("errores"), list):
|
||||
# Formato API VU: { mensaje, errores: [{ campo, mensaje, tipo_error }] }
|
||||
parts: list[str] = []
|
||||
for e in data["errores"]:
|
||||
if not isinstance(e, dict):
|
||||
continue
|
||||
campo = e.get("campo") or ""
|
||||
msg = e.get("mensaje") or ""
|
||||
if campo and msg:
|
||||
parts.append(f"{campo}: {msg}")
|
||||
elif msg:
|
||||
parts.append(msg)
|
||||
if parts:
|
||||
message = "; ".join(parts)
|
||||
if not message:
|
||||
message = resp.text or f"HTTP {resp.status_code}"
|
||||
|
||||
@@ -117,12 +123,32 @@ class CoveExternalService:
|
||||
message = data.get("message")
|
||||
external_task_id = data.get("task_id")
|
||||
|
||||
# Caso especial: algunos ambientes de VU regresan status="error" pero un mensaje
|
||||
# tipo "Factura COVE iniciada para: ... Use el task_id para consultar el estado."
|
||||
# que en realidad indica que la factura fue aceptada y quedó encolada en VU.
|
||||
# En ese caso NO lo tratamos como error de negocio, sino como "en cola".
|
||||
# Si VU respondió "queued/external_queued/success" pero no task_id, la respuesta
|
||||
# no es utilizable para polling; tratarlo como error explícito.
|
||||
if raw_status.lower() in {"queued", "external_queued", "success"} and not external_task_id:
|
||||
return CoveExternalResult(
|
||||
status="error",
|
||||
message=(
|
||||
"Respuesta inválida del servicio COVE: no se recibió task_id "
|
||||
"para consultar el estado."
|
||||
),
|
||||
cove_number=None,
|
||||
vucem_operation_num=None,
|
||||
raw_response={
|
||||
"status_code": resp.status_code,
|
||||
"body": data,
|
||||
},
|
||||
)
|
||||
|
||||
# Normalizar el status de VU a un valor interno que active el polling cuando procede.
|
||||
# El API de VU ha devuelto distintos valores según la versión:
|
||||
# - "PENDING" / "STARTED" con task_id → tarea encolada, se debe hacer polling.
|
||||
# - "error" con mensaje "Factura COVE iniciada para" → mismo caso (versión anterior).
|
||||
# - cualquier otro valor → se pasa sin modificar.
|
||||
normalized_status = raw_status.lower()
|
||||
if (
|
||||
if normalized_status in {"pending", "started"} and external_task_id:
|
||||
status = "external_queued"
|
||||
elif (
|
||||
normalized_status == "error"
|
||||
and isinstance(message, str)
|
||||
and "Factura COVE iniciada para" in message
|
||||
@@ -168,8 +194,12 @@ class CoveExternalService:
|
||||
"""
|
||||
url = f"{self.base_url.rstrip('/')}/api/v1/factura-cove/status/{task_id}"
|
||||
|
||||
with httpx.Client(timeout=30.0, verify=self.verify_ssl) as client:
|
||||
with httpx.Client(timeout=30.0, verify=self.verify_ssl, follow_redirects=False) as client:
|
||||
resp = client.get(url)
|
||||
if resp.status_code in {301, 302, 307, 308} and resp.headers.get("location"):
|
||||
redirected_url = urljoin(url, resp.headers["location"])
|
||||
logger.info("COVE status redirect: %s -> %s", url, redirected_url)
|
||||
resp = client.get(redirected_url)
|
||||
|
||||
try:
|
||||
data: Dict[str, Any] = resp.json()
|
||||
|
||||
@@ -32,6 +32,7 @@ from typing import Any, List, Optional
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from core import storage_s3
|
||||
from core.s3_keys import cove_xml_key
|
||||
import pdfkit
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -360,17 +361,29 @@ class CoveAcuseReportService:
|
||||
ctx: Any,
|
||||
) -> Optional[bytes]:
|
||||
"""
|
||||
Intenta obtener el XML de COVE asociado a la configuración VU:
|
||||
Intenta obtener el XML de COVE asociado a la factura.
|
||||
|
||||
- Primero busca en CustomsBrokerVU.xml_files_path.
|
||||
- Luego en Company.ventanilla_unica.xml_files_path.
|
||||
|
||||
Si encuentra una referencia que apunta a S3 (prefijo tenants/),
|
||||
usa storage_s3.get_object_bytes; si es una ruta local, la lee
|
||||
directamente del filesystem.
|
||||
Orden de búsqueda:
|
||||
1. Clave S3 específica por factura (guardada al recibir el XML de VU):
|
||||
``.../invoices/{invoice_id}/cove/cove.xml``
|
||||
2. CustomsBrokerVU.xml_files_path (ruta compartida legacy).
|
||||
3. Company.ventanilla_unica.xml_files_path (ruta compartida legacy).
|
||||
|
||||
Devuelve None si no encuentra o no puede leer ningún XML.
|
||||
"""
|
||||
# 1. Clave S3 específica por factura (prioridad máxima)
|
||||
invoice = getattr(ctx, "invoice", None)
|
||||
if invoice is not None:
|
||||
try:
|
||||
invoice_xml_key = cove_xml_key(invoice.tenant_id, invoice.company_id, invoice.id)
|
||||
xml_bytes = storage_s3.get_object_bytes(invoice_xml_key)
|
||||
if xml_bytes:
|
||||
logger.info("XML de COVE cargado desde S3 por factura: %s", invoice_xml_key)
|
||||
return xml_bytes
|
||||
except Exception:
|
||||
logger.debug("XML de COVE no encontrado en S3 por factura %s", getattr(invoice, "id", None))
|
||||
|
||||
# 2 & 3. Rutas configuradas en VU / empresa (modo legacy)
|
||||
candidates: list[str] = []
|
||||
|
||||
vu = getattr(ctx, "vu", None)
|
||||
@@ -403,13 +416,17 @@ class CoveAcuseReportService:
|
||||
xml_bytes: bytes,
|
||||
invoice: Any,
|
||||
compliance: Any,
|
||||
invoice_specific: bool = False,
|
||||
) -> Optional[CoveAcuseContext]:
|
||||
"""
|
||||
Intenta construir un CoveAcuseContext en base al XML de COVE.
|
||||
|
||||
- Valida que el e-document del XML coincida con compliance.edocument.
|
||||
- Si la validación falla o el XML no tiene estructura esperada,
|
||||
devuelve None.
|
||||
- Si ``invoice_specific=True`` el XML fue cargado desde la clave S3
|
||||
específica de la factura; en ese caso se omite la validación cruzada
|
||||
por e-document (ya sabemos que pertenece a esta factura) y se usa
|
||||
el primer nodo COVE válido.
|
||||
- Si ``invoice_specific=False`` (modo legacy / ruta compartida) se
|
||||
requiere que el e-document de la factura coincida con el XML.
|
||||
"""
|
||||
try:
|
||||
root = ET.fromstring(xml_bytes)
|
||||
@@ -418,30 +435,39 @@ class CoveAcuseReportService:
|
||||
return None
|
||||
|
||||
edoc_invoice = (getattr(compliance, "edocument", None) or "").strip() if compliance else ""
|
||||
if not edoc_invoice:
|
||||
# Sin e-document en la factura no podemos ligar de forma estricta el XML.
|
||||
return None
|
||||
|
||||
# Buscar el bloque XML (p. ej. <cove>...</cove>) cuyo e-document coincide
|
||||
# exactamente con el de la factura. De esta forma, aunque el archivo
|
||||
# contenga varios COVEs, solo usamos el que está ligado a la factura.
|
||||
cove_node: Optional[ET.Element] = None
|
||||
for node in root.iter():
|
||||
edoc_xml = _find_first_text(node, ["e-document", "edocument", "edocumento"])
|
||||
if edoc_xml and edoc_xml.strip().upper() == edoc_invoice.upper():
|
||||
cove_node = node
|
||||
break
|
||||
|
||||
if cove_node is None:
|
||||
# XML no contiene ningún COVE cuyo e-document coincida con la factura.
|
||||
logger.info(
|
||||
"XML de COVE no contiene bloque con e-document=%s para factura %s",
|
||||
edoc_invoice,
|
||||
getattr(invoice, "id", None),
|
||||
)
|
||||
return None
|
||||
if invoice_specific:
|
||||
# Modo sin validación cruzada: tomamos el primer nodo con datos de factura.
|
||||
for node in root.iter():
|
||||
if _find_first_text(node, ["numeroFactura", "numero_factura", "factura"]):
|
||||
cove_node = node
|
||||
break
|
||||
# Si no encontramos nodo con numero_factura, usamos root directamente.
|
||||
if cove_node is None:
|
||||
cove_node = root
|
||||
else:
|
||||
if not edoc_invoice:
|
||||
# Sin e-document en la factura no podemos ligar el XML de ruta compartida.
|
||||
return None
|
||||
|
||||
# Cabecera / datos generales (limitado al bloque de COVE encontrado)
|
||||
# Buscar el bloque cuyo e-document coincida exactamente con el de la factura.
|
||||
for node in root.iter():
|
||||
edoc_xml = _find_first_text(node, ["e-document", "edocument", "edocumento"])
|
||||
if edoc_xml and edoc_xml.strip().upper() == edoc_invoice.upper():
|
||||
cove_node = node
|
||||
break
|
||||
|
||||
if cove_node is None:
|
||||
logger.info(
|
||||
"XML de COVE no contiene bloque con e-document=%s para factura %s",
|
||||
edoc_invoice,
|
||||
getattr(invoice, "id", None),
|
||||
)
|
||||
return None
|
||||
|
||||
# Cabecera / datos generales
|
||||
tipo_operacion = _find_first_text(cove_node, ["tipoOperacion", "tipo_operacion"])
|
||||
numero_factura = _find_first_text(cove_node, ["numeroFactura", "numero_factura", "factura"])
|
||||
tipo_figura = _find_first_text(cove_node, ["tipoFigura", "tipo_figura"])
|
||||
@@ -450,14 +476,16 @@ class CoveAcuseReportService:
|
||||
rfc_consulta = _find_first_text(cove_node, ["rfcConsulta", "rfc_consulta"])
|
||||
patente_aduanal = _find_first_text(cove_node, ["patenteAduanal", "patente_aduanal"])
|
||||
|
||||
# Si no logramos obtener al menos número de factura ligado a ese bloque, consideramos inválido.
|
||||
if not numero_factura:
|
||||
if not invoice_specific and not numero_factura:
|
||||
logger.info(
|
||||
"Bloque XML de COVE con e-document=%s no tiene numeroFactura legible; se descarta.",
|
||||
edoc_invoice,
|
||||
)
|
||||
return None
|
||||
|
||||
# Si no hay e-document en la factura, intentamos extraerlo del XML.
|
||||
edoc_final = edoc_invoice or _find_first_text(cove_node, ["e-document", "edocument", "edocumento"])
|
||||
|
||||
emisor_view = _build_persona_from_xml(cove_node, ["emisor", "exportador"])
|
||||
destinatario_view = _build_persona_from_xml(cove_node, ["destinatario", "importador"])
|
||||
items = _build_items_from_xml(cove_node)
|
||||
@@ -470,7 +498,7 @@ class CoveAcuseReportService:
|
||||
observaciones=observaciones or "",
|
||||
rfc_consulta=rfc_consulta or "",
|
||||
patente_aduanal=patente_aduanal or "",
|
||||
edocument=edoc_invoice,
|
||||
edocument=edoc_final,
|
||||
vucem_operation_num=(getattr(compliance, "vucem_operation_num", None) or "") if compliance else "",
|
||||
emisor=emisor_view,
|
||||
destinatario=destinatario_view,
|
||||
@@ -488,23 +516,34 @@ class CoveAcuseReportService:
|
||||
Construye el CoveAcuseContext reutilizando FacturaCoveDomainService.
|
||||
|
||||
Flujo:
|
||||
1. Intenta leer y mapear el XML de COVE desde S3 / filesystem
|
||||
(debe existir y el e-document debe coincidir con la factura).
|
||||
2. Si no es posible, falla con ValidationException (no hay XML
|
||||
de COVE disponible para la factura).
|
||||
1. Intenta leer el XML de COVE específico de la factura desde S3
|
||||
(cargado al recibir la respuesta de VU).
|
||||
2. Si no existe, intenta desde xml_files_path configurado en VU / empresa.
|
||||
3. Si no hay XML disponible, lanza ValidationException.
|
||||
"""
|
||||
domain = FacturaCoveDomainService(db)
|
||||
ctx = domain._load_context(invoice_id, tenant_id, company_id)
|
||||
invoice = ctx.invoice
|
||||
compliance = invoice.compliance_mx
|
||||
|
||||
# Intentar construir el contexto exclusivamente a partir del XML de COVE,
|
||||
# aprovechando los archivos almacenados en S3 / filesystem.
|
||||
# Intentar primero la clave S3 específica por factura.
|
||||
invoice_specific = False
|
||||
xml_bytes: Optional[bytes] = None
|
||||
try:
|
||||
xml_bytes = self._load_cove_xml_bytes(domain, ctx)
|
||||
invoice_xml_key = cove_xml_key(tenant_id, company_id, invoice_id)
|
||||
xml_bytes = storage_s3.get_object_bytes(invoice_xml_key)
|
||||
if xml_bytes:
|
||||
invoice_specific = True
|
||||
except Exception:
|
||||
logger.warning("Error leyendo XML de COVE para factura %s", invoice.id, exc_info=True)
|
||||
xml_bytes = None
|
||||
logger.debug("XML de COVE no encontrado en S3 por factura %s, intentando ruta configurada.", invoice_id)
|
||||
|
||||
# Fallback: rutas configuradas en VU / empresa.
|
||||
if not xml_bytes:
|
||||
try:
|
||||
xml_bytes = self._load_cove_xml_bytes(domain, ctx)
|
||||
except Exception:
|
||||
logger.warning("Error leyendo XML de COVE para factura %s", invoice.id, exc_info=True)
|
||||
xml_bytes = None
|
||||
|
||||
if not xml_bytes:
|
||||
raise ValidationException(
|
||||
@@ -517,7 +556,9 @@ class CoveAcuseReportService:
|
||||
],
|
||||
)
|
||||
|
||||
xml_ctx = self._build_context_from_xml_if_possible(xml_bytes, invoice, compliance)
|
||||
xml_ctx = self._build_context_from_xml_if_possible(
|
||||
xml_bytes, invoice, compliance, invoice_specific=invoice_specific
|
||||
)
|
||||
if not xml_ctx:
|
||||
raise ValidationException(
|
||||
"No se pudo interpretar el XML de COVE asociado a la factura.",
|
||||
|
||||
@@ -24,6 +24,7 @@ from .schemas import (
|
||||
ConfiguracionVU,
|
||||
CoveEligibilityIssue,
|
||||
CoveEligibilityResponse,
|
||||
DescripcionEspecifica,
|
||||
FacturaCoveRequest,
|
||||
MercanciaCove,
|
||||
PersonaCove,
|
||||
@@ -160,19 +161,16 @@ class FacturaCoveDomainService:
|
||||
)
|
||||
clave_fiel_value = self._encrypt_fiel(str(company_fiel_secret))
|
||||
|
||||
# Validación básica de credenciales VU: usamos la clave/token efectiva
|
||||
# del web service, que es lo que realmente viaja en configuracion_vu.
|
||||
hardcoded_ws_key = (
|
||||
"RZGd+CB4R6PfSrstOyN8Is9FXL9AK9NPFisyGGaEWa0vVHoVOl8v2SBcHBoGbt3T/4uHTGcsFQO3b7EonWVfugQjBooywbz74K+jM68j8/Y="
|
||||
)
|
||||
vu_ws_key = (getattr(vu, "web_service_access_key", None) or "").strip() if vu else ""
|
||||
# La clave de webservice se almacena en texto plano en la DB y debe cifrarse
|
||||
# con el mismo esquema AES-256-CBC que la clave FIEL antes de enviarla a VUCEM.
|
||||
vu_ws_key_raw = (getattr(vu, "web_service_access_key", None) or "").strip() if vu else ""
|
||||
vu_ws_key_encrypted = self._encrypt_fiel(vu_ws_key_raw) if vu_ws_key_raw else ""
|
||||
vu_access_key_raw = (getattr(vu, "access_key", None) or "").strip() if vu else ""
|
||||
vu_access_key_encrypted = self._encrypt_fiel(vu_access_key_raw) if vu_access_key_raw else ""
|
||||
clave_webservice = (
|
||||
vu_ws_key
|
||||
vu_ws_key_encrypted
|
||||
or vu_access_key_encrypted
|
||||
or (getattr(company_vu, "webservice_password", None) or "").strip()
|
||||
or hardcoded_ws_key
|
||||
)
|
||||
|
||||
if not clave_webservice:
|
||||
@@ -599,6 +597,30 @@ class FacturaCoveDomainService:
|
||||
)
|
||||
continue
|
||||
|
||||
# Build descripcion_especifica from series entries (marca/modelo/submodelo/numero_serie)
|
||||
desc_esp: list[DescripcionEspecifica] = []
|
||||
for serie in (line.series or []):
|
||||
desc_esp.append(
|
||||
DescripcionEspecifica(
|
||||
marca=(serie.brand or "").strip()[:80],
|
||||
modelo=(serie.model or "").strip()[:80],
|
||||
submodelo=(serie.sub_model or "").strip()[:80],
|
||||
numero_serie=(serie.serial_numbers or "").strip()[:80],
|
||||
)
|
||||
)
|
||||
# Fallback: if no series rows, try building one entry from line description fields
|
||||
if not desc_esp and line.description:
|
||||
d = line.description
|
||||
if d.brand or d.model:
|
||||
desc_esp.append(
|
||||
DescripcionEspecifica(
|
||||
marca=(d.brand or "").strip()[:80],
|
||||
modelo=(d.model or "").strip()[:80],
|
||||
submodelo="",
|
||||
numero_serie="",
|
||||
)
|
||||
)
|
||||
|
||||
mercancia = MercanciaCove(
|
||||
descripcion_generica=descripcion[:500],
|
||||
clave_unidad_medida=clave_unidad,
|
||||
@@ -607,7 +629,7 @@ class FacturaCoveDomainService:
|
||||
valor_unitario=valor_unitario,
|
||||
valor_total=valor_total,
|
||||
valor_dolares=valor_dolares,
|
||||
descripcion_especifica=[],
|
||||
descripcion_especifica=desc_esp,
|
||||
)
|
||||
mercancias.append(mercancia)
|
||||
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
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
|
||||
@@ -22,19 +26,66 @@ 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 _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 (<eDocumento>) 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 == "edocumento":
|
||||
value = (node.text or "").strip()
|
||||
if value:
|
||||
edocumento = value
|
||||
|
||||
elif tag == "mensaje":
|
||||
text = (node.text or "").strip()
|
||||
if text:
|
||||
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 y el número de operación VUCEM
|
||||
cuando el servicio externo reporta SUCCESS.
|
||||
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
|
||||
|
||||
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)
|
||||
@@ -42,7 +93,6 @@ def _save_cove_result(
|
||||
|
||||
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,
|
||||
@@ -50,6 +100,23 @@ def _save_cove_result(
|
||||
)
|
||||
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 ""
|
||||
@@ -125,10 +192,52 @@ def _poll_external_status(
|
||||
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"
|
||||
)
|
||||
# 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(
|
||||
@@ -136,6 +245,7 @@ def _poll_external_status(
|
||||
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},
|
||||
)
|
||||
|
||||
@@ -160,16 +270,14 @@ def factura_cove_generate(
|
||||
recipient_email: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Tarea Celery para preparar (y en el futuro generar) un COVE a partir de una factura.
|
||||
Tarea Celery para generar COVE a partir de una factura.
|
||||
|
||||
Actualmente:
|
||||
Flujo actual:
|
||||
- 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.
|
||||
- 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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user