diff --git a/backend/api/v1/modules/a76/customs_brokers/services.py b/backend/api/v1/modules/a76/customs_brokers/services.py index 32ae8469..31bce8ee 100644 --- a/backend/api/v1/modules/a76/customs_brokers/services.py +++ b/backend/api/v1/modules/a76/customs_brokers/services.py @@ -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) diff --git a/backend/api/v1/modules/a76/factura_cove/external_service.py b/backend/api/v1/modules/a76/factura_cove/external_service.py index f9811f6c..61e9985b 100644 --- a/backend/api/v1/modules/a76/factura_cove/external_service.py +++ b/backend/api/v1/modules/a76/factura_cove/external_service.py @@ -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() diff --git a/backend/api/v1/modules/a76/factura_cove/report_service.py b/backend/api/v1/modules/a76/factura_cove/report_service.py index dc2de0a0..18042343 100644 --- a/backend/api/v1/modules/a76/factura_cove/report_service.py +++ b/backend/api/v1/modules/a76/factura_cove/report_service.py @@ -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. ...) 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.", diff --git a/backend/api/v1/modules/a76/factura_cove/service.py b/backend/api/v1/modules/a76/factura_cove/service.py index c7a57df9..5e20ed3f 100644 --- a/backend/api/v1/modules/a76/factura_cove/service.py +++ b/backend/api/v1/modules/a76/factura_cove/service.py @@ -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) diff --git a/backend/api/v1/modules/a76/factura_cove/tasks.py b/backend/api/v1/modules/a76/factura_cove/tasks.py index 76a6a09c..c5e42129 100644 --- a/backend/api/v1/modules/a76/factura_cove/tasks.py +++ b/backend/api/v1/modules/a76/factura_cove/tasks.py @@ -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ó 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 == "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() diff --git a/backend/core/s3_keys.py b/backend/core/s3_keys.py index 41767d83..8866b803 100644 --- a/backend/core/s3_keys.py +++ b/backend/core/s3_keys.py @@ -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, diff --git a/frontend/src/lib/api/dashboard/a76/customs-brokers.ts b/frontend/src/lib/api/dashboard/a76/customs-brokers.ts index 026aeeb2..94bb5b7b 100644 --- a/frontend/src/lib/api/dashboard/a76/customs-brokers.ts +++ b/frontend/src/lib/api/dashboard/a76/customs-brokers.ts @@ -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); diff --git a/frontend/src/lib/components/dashboard/goods/modales/us-fraction-selector-dialog.svelte b/frontend/src/lib/components/dashboard/goods/modales/us-fraction-selector-dialog.svelte index 206ab402..cb42af8d 100644 --- a/frontend/src/lib/components/dashboard/goods/modales/us-fraction-selector-dialog.svelte +++ b/frontend/src/lib/components/dashboard/goods/modales/us-fraction-selector-dialog.svelte @@ -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); diff --git a/frontend/src/lib/config/shortcuts/dashboard/customs_brokers/edit.ts b/frontend/src/lib/config/shortcuts/dashboard/customs_brokers/edit.ts index 2c66f8ee..74ca2d7c 100644 --- a/frontend/src/lib/config/shortcuts/dashboard/customs_brokers/edit.ts +++ b/frontend/src/lib/config/shortcuts/dashboard/customs_brokers/edit.ts @@ -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', diff --git a/frontend/src/routes/dashboard/customs_brokers/edit/[[id]]/+page.svelte b/frontend/src/routes/dashboard/customs_brokers/edit/[[id]]/+page.svelte index f08dcc61..3cfe017c 100644 --- a/frontend/src/routes/dashboard/customs_brokers/edit/[[id]]/+page.svelte +++ b/frontend/src/routes/dashboard/customs_brokers/edit/[[id]]/+page.svelte @@ -400,7 +400,6 @@ irDireccion: () => (activeTab = 'address'), irVU: () => (activeTab = 'vu'), irDoda: () => (activeTab = 'doda'), - irAnam: () => (activeTab = 'anam'), guardar: handleSave, cancelar: handleCancel }) @@ -891,6 +890,38 @@ +
+
+ + +
+
+ + +
+
+
@@ -1125,14 +1119,13 @@
- - General - Contacto - Domicilio - VU - DODA - ANAM - + + General + Contacto + Domicilio + VU + DODA +
diff --git a/frontend/src/routes/dashboard/invoices/+page.svelte b/frontend/src/routes/dashboard/invoices/+page.svelte index 9fd6feae..6d58da6c 100644 --- a/frontend/src/routes/dashboard/invoices/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/+page.svelte @@ -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(); }} > @@ -1947,7 +1957,7 @@ toast.info(m.invoice_list_submenu_consult_soon())} + onclick={() => openCoveDialog()} > {m.invoice_list_footer_vu_consult()}