feature/vu-api-acuse-xml-pdf

This commit is contained in:
2026-04-30 11:07:03 -06:00
parent e8db8f2a86
commit b5c94ca0cc
11 changed files with 387 additions and 162 deletions

View File

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

View File

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

View File

@@ -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.",

View File

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

View File

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

View File

@@ -344,6 +344,19 @@ def expediente_archivo_artifact_key(
return f"{tenant_company_prefix(tenant_id, company_id)}expediente_archivos/{eid}/artifacts/{at}_{ts}{ext}"
def cove_xml_key(
tenant_id: Union[int, str],
company_id: int,
invoice_id: int,
) -> str:
"""
XML de COVE devuelto por Ventanilla Única, bajo
``.../invoices/{invoice_id}/cove/cove.xml`` (clave estable por factura).
"""
iid = _segment(invoice_id, "invoice_id")
return f"{tenant_company_prefix(tenant_id, company_id)}invoices/{iid}/cove/cove.xml"
def cove_acuse_pdf_key(
tenant_id: Union[int, str],
company_id: int,

View File

@@ -173,11 +173,10 @@ export const customsBrokersApi = {
console.warn("WARN: companyId no fue provisto a updateVU, usando companyStore:", finalCompanyId);
}
// Aseguramos que el payload tenga los IDs
// No enviar tenant_id/company_id en payload VU:
// el backend los resuelve por token + query param.
const payload = {
...data,
company_id: finalCompanyId,
tenant_id: finalCompanyId
...data
};
console.log('[DEBUG] Enviando payload VU:', payload);

View File

@@ -5,10 +5,8 @@
import * as Table from "$lib/components/ui/table";
import { toast } from "svelte-sonner";
import { Search, Loader2, Hash } from "lucide-svelte";
import {
getTariffFractions,
type TariffFraction
} from "$lib/api/dashboard/a76/general_catalogs/tariff-fractions";
import type { TariffFraction } from "$lib/api/dashboard/a76/general_catalogs/tariff-fractions";
import { getUSTariffFractions } from "$lib/api/dashboard/a76/general_catalogs/us-tariff-fractions";
import { getTariffFractionDisplayKey } from '$lib/utils/tariff-fraction-display';
import { companyStore } from "$lib/stores/company.svelte";
import { m } from '$lib/i18n/messages';
@@ -66,6 +64,19 @@
}
});
function mapUsFractionToTariffFraction(item: any): TariffFraction {
return {
id: item.id,
code: item.code || '',
fraction: item.fraction || item.code || '',
description: item.description || '',
nico: null,
umt: item.unit_of_measure || null,
adv_impo: item.ad_valorem != null ? String(item.ad_valorem) : null,
adv_expo: null
};
}
async function loadFractions(companyId: number) {
if (!companyId) {
toast.error(m.invoice_selectors_us_tariff_fraction_no_company_selected());
@@ -74,9 +85,7 @@
loading = true;
try {
const response = await getTariffFractions(1, 1000, companyId, {
catalog: 'usa'
});
const response = await getUSTariffFractions(1, 1000, companyId);
if (response.error) {
console.error("Error al cargar fracciones americanas:", response.error);
@@ -85,7 +94,9 @@
}
if (response.data?.items) {
items = response.data.items.filter((item) => isEligibleAmericanFraction(item));
items = response.data.items
.map(mapUsFractionToTariffFraction)
.filter((item) => isEligibleAmericanFraction(item));
loadedForCompanyId = companyId;
} else {
console.warn("No se encontraron fracciones americanas:", response);

View File

@@ -6,7 +6,6 @@ export const obtenerAtajosEdicionAgente = (acciones: {
irDireccion: () => void;
irVU: () => void;
irDoda: () => void;
irAnam: () => void;
guardar: () => void;
cancelar: () => void;
}): ShortcutDef[] => [
@@ -35,11 +34,6 @@ export const obtenerAtajosEdicionAgente = (acciones: {
description: 'Tab DODA',
action: acciones.irDoda
},
{
key: 'Alt+Digit6',
description: 'Tab ANAM',
action: acciones.irAnam
},
{
key: 'Ctrl+S',
description: 'Guardar',

View File

@@ -400,7 +400,6 @@
irDireccion: () => (activeTab = 'address'),
irVU: () => (activeTab = 'vu'),
irDoda: () => (activeTab = 'doda'),
irAnam: () => (activeTab = 'anam'),
guardar: handleSave,
cancelar: handleCancel
})
@@ -891,6 +890,38 @@
</div>
</div>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div class="grid gap-2">
<Label
>Usuario Web Service VU <User
size={14}
class="ml-1 inline text-muted-foreground"
/></Label
>
<Input
bind:value={vuData.web_service_user}
placeholder="Usuario VU"
disabled={loading}
class="h-10"
/>
</div>
<div class="grid gap-2">
<Label
>Clave Web Service VU <Lock
size={14}
class="ml-1 inline text-muted-foreground"
/></Label
>
<Input
type="password"
bind:value={vuData.web_service_access_key}
placeholder="********"
disabled={loading}
class="h-10"
/>
</div>
</div>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div class="grid gap-2">
<Label
@@ -1077,43 +1108,6 @@
</Card.Content>
</Card.Root>
</Tabs.Content>
<Tabs.Content value="anam">
<Card.Root>
<Card.Header>
<Card.Title>ANAM</Card.Title>
<Card.Description>Configuración de acceso para ANAM.</Card.Description>
</Card.Header>
<Card.Content class="space-y-6">
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div class="grid gap-2">
<Label>Usuario <User size={14} class="ml-1 inline text-muted-foreground" /></Label
>
<Input
bind:value={vuData.web_service_user}
placeholder="Usuario ANAM"
disabled={loading}
class="h-10"
/>
</div>
<div class="grid gap-2">
<Label
>Clave acceso <Lock
size={14}
class="ml-1 inline text-muted-foreground"
/></Label
>
<Input
type="password"
bind:value={vuData.web_service_access_key}
placeholder="********"
disabled={loading}
class="h-10"
/>
</div>
</div>
</Card.Content>
</Card.Root>
</Tabs.Content>
</form>
</div>
</div>
@@ -1125,14 +1119,13 @@
<div class="mx-auto max-w-[1400px] space-y-4 px-4 py-4">
<!-- Footer Navigation -->
<div class="w-full overflow-x-auto pb-2">
<Tabs.List class="grid w-full grid-cols-6">
<Tabs.Trigger value="general">General</Tabs.Trigger>
<Tabs.Trigger value="contact">Contacto</Tabs.Trigger>
<Tabs.Trigger value="address">Domicilio</Tabs.Trigger>
<Tabs.Trigger value="vu">VU</Tabs.Trigger>
<Tabs.Trigger value="doda">DODA</Tabs.Trigger>
<Tabs.Trigger value="anam">ANAM</Tabs.Trigger>
</Tabs.List>
<Tabs.List class="grid w-full grid-cols-5">
<Tabs.Trigger value="general">General</Tabs.Trigger>
<Tabs.Trigger value="contact">Contacto</Tabs.Trigger>
<Tabs.Trigger value="address">Domicilio</Tabs.Trigger>
<Tabs.Trigger value="vu">VU</Tabs.Trigger>
<Tabs.Trigger value="doda">DODA</Tabs.Trigger>
</Tabs.List>
</div>
<!-- Actions -->

View File

@@ -909,9 +909,19 @@
const taskInfo = result.external_task_id ? ` (task_id: ${result.external_task_id})` : '';
toast.success(result.message + taskInfo);
} else {
const resolvedError =
(typeof result === 'string' && result.trim()) ||
(typeof result?.message === 'string' && result.message.trim()) ||
(typeof result?.error === 'string' && result.error.trim()) ||
(typeof result?.detail === 'string' && result.detail.trim()) ||
(typeof result?.result === 'string' && result.result.trim()) ||
(Array.isArray(result?.errors) &&
typeof result.errors[0]?.message === 'string' &&
result.errors[0].message.trim()) ||
m.invoice_table_not_available_short();
toast.error(
m.invoice_list_toasts_worker_error_prefix({
error: String(result.message || m.invoice_table_not_available_short())
error: String(resolvedError)
})
);
}
@@ -1744,7 +1754,7 @@
class="flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent"
onclick={() => {
showVuSubmenu = false;
toast.info(m.invoice_list_submenu_consult_soon());
openCoveDialog();
}}
>
<Files class="mr-2 h-4 w-4" />
@@ -1947,7 +1957,7 @@
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent class="w-56">
<DropdownMenu.Item
onclick={() => toast.info(m.invoice_list_submenu_consult_soon())}
onclick={() => openCoveDialog()}
>
<Files class="mr-2 h-4 w-4" />
{m.invoice_list_footer_vu_consult()}