From 3a636c14ae896aa67491664bacb879c95df34f34 Mon Sep 17 00:00:00 2001 From: Dulce Date: Mon, 18 May 2026 11:51:30 -0600 Subject: [PATCH] T2026-05-030 --- api/cards/views.py | 2 +- api/customs/serializers.py | 58 +-- api/customs/signals/procesamiento.py | 14 +- api/customs/tasks/__init__.py | 1 + api/customs/tasks/auditoria.py | 275 +++++++++---- api/customs/tasks/auditoria_xml.py | 35 +- api/customs/tasks/microservice_v2.py | 67 +++- api/customs/views_auditor.py | 576 +++++++++++++++------------ api/record/tests.py | 180 ++++++++- 9 files changed, 825 insertions(+), 383 deletions(-) diff --git a/api/cards/views.py b/api/cards/views.py index 7c59710..ba630e4 100644 --- a/api/cards/views.py +++ b/api/cards/views.py @@ -157,7 +157,7 @@ class ViewPedimentoServicesUtilInformation(LoggingMixin, APIView, FiltroPorOrgan # Si es importador de la organizacion, devuelve los servicios relacionados con sus pedimentos if self.request.user.is_authenticated and self.request.user.groups.filter(name='importador').exists() and self.request.user.is_importador and self.request.user.groups.filter(name='user').exists(): - return self.request.user.organizacion.procesamiento_pedimentos.filter(pedimento__contribuyente=self.request.user.rfc) + return self.request.user.organizacion.procesamiento_pedimentos.filter(pedimento__contribuyente__in=self.request.user.rfc.all()) diff --git a/api/customs/serializers.py b/api/customs/serializers.py index ec675b5..d43f0f4 100644 --- a/api/customs/serializers.py +++ b/api/customs/serializers.py @@ -47,55 +47,31 @@ class PartidaSerializer(serializers.ModelSerializer): documentos = serializers.SerializerMethodField() def get_documentos(self, obj): - """ - Busca documentos en la tabla `document` que coincidan EXACTAMENTE con: - 'documents/vu_PT_{pedimentoApp}_{numero}' al inicio del nombre del archivo. - """ - if not obj or not getattr(obj, 'pedimento', None): return [] - if not obj or not getattr(obj, 'numero_partida', None): return [] try: - pedimentoApp = str(obj.pedimento.pedimento_app).strip() + pedimento_app = str(obj.pedimento.pedimento_app).strip() numero = str(obj.numero_partida).strip() + # Incluir pedimento_app en el patrón para evitar falsos positivos + # entre partidas con números cortos (1 matchearía 10, 100, etc.) + patron = f"vu_PT_{pedimento_app}_{numero}_" - # Construir el patrón exacto de búsqueda - patron_exacto = f'documents/vu_PT_{pedimentoApp}_{numero}.xml' - - # Buscar documentos que empiecen EXACTAMENTE con ese patrón + # 17 = REQUEST partida, 18 = ERROR partida qs = Document.objects.filter( - archivo=patron_exacto - ) + pedimento=obj.pedimento, + archivo__icontains=patron, + ).exclude(document_type_id__in=[17, 18]) - # Opción 2: Si puede tener diferentes extensiones - # patron_base = f'documents/vu_PT_{pedimentoApp}_{numero}' - # qs = Document.objects.filter( - # archivo__startswith=patron_base - # ).filter( - # archivo__in=[ - # f'{patron_base}.xml', - # f'{patron_base}.pdf', - # f'{patron_base}.zip' - # ] - # ) - - # Filtro adicional por pedimento si el modelo Document tiene este campo - if hasattr(Document, 'pedimento'): - qs = qs.filter(pedimento=obj.pedimento) - - # Filtro por organización if hasattr(obj, 'organizacion') and obj.organizacion: qs = qs.filter(organizacion=obj.organizacion) - + serializer = DocumentSerializer(qs, many=True, context=self.context) return serializer.data - - #return [] + except Exception: - # En caso de cualquier error (por ejemplo, importaciones circulares), devolver lista vacía return [] class Meta: model = Partida @@ -208,10 +184,11 @@ class EDocumentSerializer(serializers.ModelSerializer): numero = str(obj.numero_edocument).strip() # id_pedimento = str(obj.pedimento_id).strip() + # excluir e documents de tipo request y de tipo error qs = Document.objects.filter( pedimento=obj.pedimento, archivo__icontains=numero, - ) + ).exclude(document_type_id__in=[21, 25]) # Filtro por organización si aplica if hasattr(obj, 'organizacion') and obj.organizacion: @@ -263,18 +240,23 @@ class CoveSerializer(serializers.ModelSerializer): try: numero = str(obj.numero_cove).strip() + # Excluir los tipo de documento 20, 24, 23 y 19 + # 20 = error solicitud cove + # 24 = error solicitud acuse cove + # 23 = request acuse cove + # 19 = request cove qs = Document.objects.filter( pedimento=obj.pedimento, archivo__icontains=numero, - ) + ).exclude(document_type_id__in=[20, 24, 23, 19]) # Filtro por organización si aplica if hasattr(obj, 'organizacion') and obj.organizacion: qs = qs.filter(organizacion=obj.organizacion) - + serializer = DocumentSerializer(qs, many=True, context=self.context) return serializer.data - + except Exception: # En caso de cualquier error (por ejemplo, importaciones circulares), devolver lista vacía return [] diff --git a/api/customs/signals/procesamiento.py b/api/customs/signals/procesamiento.py index 05e6d91..1b1e163 100644 --- a/api/customs/signals/procesamiento.py +++ b/api/customs/signals/procesamiento.py @@ -87,8 +87,11 @@ def trigger_celery_task_on_cove_create(sender, instance, created, **kwargs): import logging logger = logging.getLogger('api.customs.async_operations') logger.info(f"Cove creado: {instance.id}, creando procesamiento...") - crear_procesamiento_cove.apply_async(args=[str(instance.pedimento.id)]) - crear_procesamiento_acuse_cove.apply_async(args=[str(instance.pedimento.id)]) + pedimento_id = str(instance.pedimento.id) + def enqueue_cove_tasks(): + crear_procesamiento_cove.apply_async(args=[pedimento_id]) + crear_procesamiento_acuse_cove.apply_async(args=[pedimento_id]) + transaction.on_commit(enqueue_cove_tasks) @receiver(post_save, sender=EDocument) def trigger_celery_task_on_edocument_create(sender, instance, created, **kwargs): @@ -96,5 +99,8 @@ def trigger_celery_task_on_edocument_create(sender, instance, created, **kwargs) import logging logger = logging.getLogger('api.customs.async_operations') logger.info(f"EDocument creado: {instance.id}, creando procesamiento...") - crear_procesamiento_edocument.apply_async(args=[str(instance.pedimento.id)]) - crear_procesamiento_acuse.apply_async(args=[str(instance.pedimento.id)]) \ No newline at end of file + pedimento_id = str(instance.pedimento.id) + def enqueue_edocument_tasks(): + crear_procesamiento_edocument.apply_async(args=[pedimento_id]) + crear_procesamiento_acuse.apply_async(args=[pedimento_id]) + transaction.on_commit(enqueue_edocument_tasks) \ No newline at end of file diff --git a/api/customs/tasks/__init__.py b/api/customs/tasks/__init__.py index e63a0d3..8e07ede 100644 --- a/api/customs/tasks/__init__.py +++ b/api/customs/tasks/__init__.py @@ -1,3 +1,4 @@ from .microservice import * from .internal_services import * from .bulk_upload import * +from .microservice_v2 import * diff --git a/api/customs/tasks/auditoria.py b/api/customs/tasks/auditoria.py index e0ff4e0..aa9e770 100644 --- a/api/customs/tasks/auditoria.py +++ b/api/customs/tasks/auditoria.py @@ -6,6 +6,8 @@ from api.customs.models import ProcesamientoPedimento, Pedimento, Cove, EDocumen from core.utils import xml_controller import requests from core.utils import xml_remesas_controller +import logging +logger = logging.getLogger(__name__) def obtener_pedimentos(organizacion_id): return Pedimento.objects.filter(organizacion_id=organizacion_id) @@ -35,23 +37,31 @@ def auditor_descargas(pedimento, servicio, related_name, variable, mensaje): pedimento_id = pedimento.id docs = getattr(pedimento, related_name).all() + print(f"pedimento: {pedimento}, servicio: {servicio}, related_name: {related_name}, variable: {variable}, mensaje: {mensaje}") + logger.info(f"pedimento: {pedimento}, servicio: {servicio}, related_name: {related_name}, variable: {variable}, mensaje: {mensaje}") + # Si no hay documentos, marcar como completado if not docs.exists(): proceso = modificar_estado_procesamiento(pedimento, servicio_id=servicio, nuevo_estado=3) # Estado "completado" print(f"✓ Pedimento {pedimento_id} no tiene {mensaje}s para procesar.") + logger.info(f"✓ Pedimento {pedimento_id} no tiene {mensaje}s para procesar.") else: all_docs = all(getattr(doc, variable) for doc in docs) if all_docs: proceso = modificar_estado_procesamiento(pedimento, servicio_id=servicio, nuevo_estado=3) # Estado "completado" print(f"✓ Pedimento {pedimento_id} tiene todos sus {mensaje} descargados.") + logger.info(f"✓ Pedimento {pedimento_id} tiene todos sus {mensaje} descargados.") else: proceso = modificar_estado_procesamiento(pedimento, servicio_id=servicio, nuevo_estado=4) # Estado "en progreso" print(f"✗ Pedimento {pedimento_id} NO tiene todos sus {mensaje} descargados.") + logger.info(f"✗ Pedimento {pedimento_id} NO tiene todos sus {mensaje} descargados.") if proceso: print(f"✓ Proceso de auditoría para pedimento {pedimento_id} completado.") + logger.info(f"✓ Proceso de auditoría para pedimento {pedimento_id} completado.") else: print(f"✗ No se encontró proceso de auditoría para pedimento {pedimento_id}.") + logger.info(f"✗ No se encontró proceso de auditoría para pedimento {pedimento_id}.") ## Auditar pedimentos @@ -121,44 +131,66 @@ def auditar_procesamiento_remesa_por_pedimento(pedimento_id): @shared_task def crear_partidas(organizacion_id): + from api.customs.models import Partida + pedimentos = obtener_pedimentos(organizacion_id) total_pedimentos = pedimentos.count() - pedimentos_procesados = 0 - total_partidas_agregadas = 0 - print(f"Iniciando procesamiento de {total_pedimentos} pedimentos para organización {organizacion_id}") + completados = [] + con_pendientes = [] + sin_datos = [] + errores = [] for pedimento in pedimentos: - pedimentos_procesados += 1 - partidas_agregadas_pedimento = 0 - - # Validar que numero_partidas no sea None y sea mayor que 0 - if pedimento.numero_partidas is not None and pedimento.numero_partidas > 0: - partidas_existentes = pedimento.partidas.count() - if pedimento.numero_partidas > partidas_existentes: - print(f"Procesando pedimento {pedimento.id} ({pedimentos_procesados}/{total_pedimentos}) - Partidas existentes: {partidas_existentes}, Requeridas: {pedimento.numero_partidas}") - - for i in range(1, pedimento.numero_partidas + 1): - from api.customs.models import Partida - partida, created = Partida.objects.get_or_create( - pedimento=pedimento, - numero_partida=i, - organizacion_id=organizacion_id - ) - if created: - partidas_agregadas_pedimento += 1 - total_partidas_agregadas += 1 - - print(f" → Partidas agregadas para pedimento {pedimento.id}: {partidas_agregadas_pedimento}") - else: - print(f"Pedimento {pedimento.id} ya tiene todas sus partidas ({partidas_existentes}/{pedimento.numero_partidas})") - else: - print(f"Pedimento {pedimento.id} omitido - numero_partidas: {pedimento.numero_partidas} (inválido)") + try: + if not pedimento.numero_partidas or pedimento.numero_partidas <= 0: + sin_datos.append({ + 'pedimento_id': str(pedimento.id), + 'pedimento': pedimento.pedimento, + 'razon': f'numero_partidas inválido ({pedimento.numero_partidas})', + }) + continue - print(f"\n=== RESUMEN ===") - print(f"Pedimentos procesados: {pedimentos_procesados}") - print(f"Total de partidas agregadas: {total_partidas_agregadas}") - print(f"Procesamiento completado para organización {organizacion_id}") + for i in range(1, pedimento.numero_partidas + 1): + Partida.objects.get_or_create( + pedimento=pedimento, + numero_partida=i, + defaults={'organizacion_id': organizacion_id} + ) + + partidas = list(pedimento.partidas.order_by('numero_partida')) + no_descargadas = [p.numero_partida for p in partidas if not p.descargado] + + if not no_descargadas: + completados.append(str(pedimento.id)) + else: + con_pendientes.append({ + 'pedimento_id': str(pedimento.id), + 'pedimento': pedimento.pedimento, + 'total_partidas': len(partidas), + 'descargadas': len(partidas) - len(no_descargadas), + 'no_descargadas': no_descargadas, + }) + + except Exception as e: + errores.append({ + 'pedimento_id': str(pedimento.id), + 'pedimento': pedimento.pedimento, + 'error': str(e), + }) + logger.error(f"Error creando partidas para pedimento {pedimento.id}: {e}") + + return { + 'organizacion_id': str(organizacion_id), + 'total_pedimentos': total_pedimentos, + 'completados': len(completados), + 'con_pendientes': len(con_pendientes), + 'sin_datos': len(sin_datos), + 'con_errores': len(errores), + 'detalle_pendientes': con_pendientes, + 'detalle_sin_datos': sin_datos, + 'detalle_errores': errores, + } @shared_task def crear_partidas_por_pedimento(pedimento_id): @@ -169,6 +201,7 @@ def crear_partidas_por_pedimento(pedimento_id): return print(f"Procesando pedimento individual {pedimento_id}...") + logger.info(f"Procesando pedimento individual {pedimento_id}...") partidas_agregadas = 0 # Validar que numero_partidas no sea None y sea mayor que 0 @@ -176,6 +209,7 @@ def crear_partidas_por_pedimento(pedimento_id): partidas_existentes = pedimento.partidas.count() if pedimento.numero_partidas > partidas_existentes: print(f"Pedimento {pedimento_id} - Partidas existentes: {partidas_existentes}, Requeridas: {pedimento.numero_partidas}") + logger.info(f"Pedimento {pedimento_id} - Partidas existentes: {partidas_existentes}, Requeridas: {pedimento.numero_partidas}") for i in range(1, pedimento.numero_partidas + 1): from api.customs.models import Partida @@ -188,62 +222,165 @@ def crear_partidas_por_pedimento(pedimento_id): partidas_agregadas += 1 print(f"✓ Partidas agregadas para pedimento {pedimento_id}: {partidas_agregadas}") + logger.info(f"✓ Partidas agregadas para pedimento {pedimento_id}: {partidas_agregadas}") else: print(f"Pedimento {pedimento_id} ya tiene todas sus partidas ({partidas_existentes}/{pedimento.numero_partidas})") + logger.info(f"Pedimento {pedimento_id} ya tiene todas sus partidas ({partidas_existentes}/{pedimento.numero_partidas})") else: print(f"Error: Pedimento {pedimento_id} tiene numero_partidas inválido: {pedimento.numero_partidas}") + logger.info(f"Error: Pedimento {pedimento_id} tiene numero_partidas inválido: {pedimento.numero_partidas}") -# Auditar coves +def _auditar_organizacion(organizacion_id, servicio, related_name, variable, label): + """ + Itera todos los pedimentos de una organización auditando el campo `variable` + en la relación `related_name`. Retorna un resumen estructurado por pedimento. + """ + pedimentos = obtener_pedimentos(organizacion_id) + total_pedimentos = pedimentos.count() + + completados = [] + pendientes = [] + errores = [] + + for pedimento in pedimentos: + try: + docs = list(getattr(pedimento, related_name).all()) + total = len(docs) + faltantes = [ + getattr(doc, 'numero_cove', None) or getattr(doc, 'numero_edocument', None) + for doc in docs if not getattr(doc, variable) + ] + + if total == 0 or len(faltantes) == 0: + nuevo_estado = 3 + completados.append(str(pedimento.id)) + else: + nuevo_estado = 4 + pendientes.append({ + 'pedimento_id': str(pedimento.id), + 'pedimento': pedimento.pedimento, + f'faltantes_{label}': faltantes, + 'total': total, + 'descargados': total - len(faltantes), + }) + + modificar_estado_procesamiento(pedimento, servicio_id=servicio, nuevo_estado=nuevo_estado) + + except Exception as e: + errores.append({ + 'pedimento_id': str(pedimento.id), + 'pedimento': pedimento.pedimento, + 'error': str(e), + }) + logger.error(f"Error auditando pedimento {pedimento.id} [{label}]: {e}") + + return { + 'organizacion_id': str(organizacion_id), + 'auditoria': label, + 'total_pedimentos': total_pedimentos, + 'completados': len(completados), + 'con_pendientes': len(pendientes), + 'con_errores': len(errores), + 'detalle_pendientes': pendientes, + 'detalle_errores': errores, + } + + @shared_task def auditar_coves(organizacion_id): - for pedimento in obtener_pedimentos(organizacion_id): - auditor_descargas( - pedimento, - servicio=8, - related_name='coves', - variable='cove_descargado', - mensaje='COVE' - ) + return _auditar_organizacion( + organizacion_id, + servicio=8, + related_name='coves', + variable='cove_descargado', + label='cove', + ) @shared_task def auditar_acuse_cove(organizacion_id): - for pedimento in obtener_pedimentos(organizacion_id): - auditor_descargas( - pedimento, - servicio=9, - related_name='coves', - variable='acuse_cove_descargado', - mensaje='acuse de COVE' - ) + return _auditar_organizacion( + organizacion_id, + servicio=9, + related_name='coves', + variable='acuse_cove_descargado', + label='acuse_cove', + ) -# Revisa si el pedimento completo todos sus acuse coves - -# Auditar edocuments @shared_task def auditar_edocuments(organizacion_id): - for pedimento in obtener_pedimentos(organizacion_id): - auditor_descargas( - pedimento, - servicio=7, - related_name='documentos', - variable='edocument_descargado', - mensaje='EDocument' - ) - + return _auditar_organizacion( + organizacion_id, + servicio=7, + related_name='documentos', + variable='edocument_descargado', + label='edocument', + ) + @shared_task def auditar_acuse(organizacion_id): - for pedimento in obtener_pedimentos(organizacion_id): - auditor_descargas( - pedimento, - servicio=6, - related_name='documentos', - variable='acuse_descargado', - mensaje='acuse' - ) + return _auditar_organizacion( + organizacion_id, + servicio=6, + related_name='documentos', + variable='acuse_descargado', + label='acuse', + ) + +@shared_task +def auditar_remesas(organizacion_id): + """ + Audita el estado de descarga de remesas para todos los pedimentos de una organización. + A diferencia de coves/edocuments, las remesas no tienen campo booleano propio — + se verifica la existencia de un documento de tipo 3 (Remesa) en el pedimento. + """ + pedimentos = obtener_pedimentos(organizacion_id) + total_pedimentos = pedimentos.count() + + completados = [] + pendientes = [] + errores = [] + + for pedimento in pedimentos: + try: + if not pedimento.remesas: + # El pedimento no declara remesas — no aplica, marcar como completado + modificar_estado_procesamiento(pedimento, servicio_id=5, nuevo_estado=3) + completados.append(str(pedimento.id)) + elif pedimento.documents.filter(document_type=3).exists(): + # Documento de remesa ya descargado + modificar_estado_procesamiento(pedimento, servicio_id=5, nuevo_estado=3) + completados.append(str(pedimento.id)) + else: + # Tiene remesas declaradas pero el documento aún no existe + modificar_estado_procesamiento(pedimento, servicio_id=5, nuevo_estado=4) + pendientes.append({ + 'pedimento_id': str(pedimento.id), + 'pedimento': pedimento.pedimento, + }) + except Exception as e: + errores.append({ + 'pedimento_id': str(pedimento.id), + 'pedimento': pedimento.pedimento, + 'error': str(e), + }) + logger.error(f"Error auditando remesa de pedimento {pedimento.id}: {e}") + + return { + 'organizacion_id': str(organizacion_id), + 'auditoria': 'remesa', + 'total_pedimentos': total_pedimentos, + 'completados': len(completados), + 'con_pendientes': len(pendientes), + 'con_errores': len(errores), + 'detalle_pendientes': pendientes, + 'detalle_errores': errores, + } @shared_task def auditar_cove_por_pedimento(pedimento_id): try: + print(f"auditar_cove_por_pedimento >>>> {pedimento_id}") + logger.info(f"auditar_cove_por_pedimento >>>> {pedimento_id}") from api.customs.models import Pedimento pedimento = Pedimento.objects.get(id=pedimento_id) auditor_descargas( diff --git a/api/customs/tasks/auditoria_xml.py b/api/customs/tasks/auditoria_xml.py index ac5226c..a7b32d4 100644 --- a/api/customs/tasks/auditoria_xml.py +++ b/api/customs/tasks/auditoria_xml.py @@ -1,6 +1,8 @@ # auditoria_xml.py import xml.etree.ElementTree as ET from datetime import datetime +import logging +logger = logging.getLogger('api.customs.auditoria_xml') def extraer_info_pedimento_xml(xml_content): """ @@ -13,8 +15,10 @@ def extraer_info_pedimento_xml(xml_content): # Buscar el namespace (puede variar) namespaces = { 'S': 'http://schemas.xmlsoap.org/soap/envelope/', + 's': 'http://schemas.xmlsoap.org/soap/envelope/', 'ns2': 'http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/consultarpedimentocompleto', - 'ns3': 'http://www.ventanillaunica.gob.mx/common/ws/oxml/respuesta' + 'ns3': 'http://www.ventanillaunica.gob.mx/common/ws/oxml/respuesta', + } resultado = {} @@ -181,10 +185,37 @@ def extraer_info_pedimento_xml(xml_content): if edocs_encontrados: resultado['edocuments_en_xml'] = edocs_encontrados - # Verificar si hay error en la respuesta + # Verificar si hay error en la respuesta — 3 variantes según el servicio VUCEM: + # 1) Remesas/pedimentos: en namespace oxml/respuesta + # 2) eDocuments: en namespace tempuri.org, mensaje en + # 3) Acuses: sin namespace dentro de responseConsultaAcuses tiene_error = root.find('.//ns3:tieneError', namespaces) if tiene_error is not None: resultado['tiene_error'] = tiene_error.text.lower() == 'true' + if resultado['tiene_error']: + mensaje = root.find('.//ns3:error/ns3:mensaje', namespaces) + if mensaje is not None and mensaje.text: + resultado['error_mensaje'] = mensaje.text.strip() + else: + # Variante eDocuments (tempuri.org) + tiene_error_edoc = root.find('.//{http://tempuri.org/}TieneError') + if tiene_error_edoc is not None: + resultado['tiene_error'] = tiene_error_edoc.text.lower() == 'true' + if resultado['tiene_error']: + errores_elem = root.find('.//{http://tempuri.org/}Errores') + if errores_elem is not None and errores_elem.text: + resultado['error_mensaje'] = errores_elem.text.strip() + else: + # Variante acuses: sin namespace + error_acuses = root.find('.//error') + if error_acuses is not None and error_acuses.text is not None: + resultado['tiene_error'] = error_acuses.text.lower() == 'true' + if resultado['tiene_error']: + descripciones = root.findall('.//mensajeErrores/descripcion') + if descripciones: + resultado['error_mensaje'] = ' | '.join( + d.text.strip() for d in descripciones if d.text + ) return resultado diff --git a/api/customs/tasks/microservice_v2.py b/api/customs/tasks/microservice_v2.py index d96862e..95b4369 100644 --- a/api/customs/tasks/microservice_v2.py +++ b/api/customs/tasks/microservice_v2.py @@ -1,3 +1,4 @@ +from api.organization.models import Organizacion from celery import group from celery import shared_task, group from api.customs.models import * @@ -8,6 +9,11 @@ import requests from config.settings import SERVICE_API_URL_V2 from datetime import datetime import json +import logging +import uuid +# este solo fue para pruebas personales, lo dejo por si en un futuro lo requiero +TEST_ORG_ID = uuid.UUID('defc7848-4f39-4d67-9dba-5bb445248d23') +logger = logging.getLogger('api.customs.microservice_v2') def credenciales_to_dict(credenciales): if not credenciales: @@ -132,7 +138,7 @@ def procesar_edocs_pedimento(pedimento_id): } response = requests.post( - f"{SERVICE_API_URL_V2}/services/download/edoc/", + f"{SERVICE_API_URL_V2}/services/download/all/edocs/", data=json.dumps(payload), headers={"Content-Type": "application/json"} ) @@ -277,27 +283,40 @@ def procesar_remesas(organizacion_id): pedimentos = Pedimento.objects.filter(organizacion_id=organizacion_id) for pedimento in pedimentos: - if not pedimento.documents.filter(document_type=3).exists(): # Tipo 3: Remesa - # Convertir el pedimento a JSON usando el serializer + logger.info(f"pedimento >>>> {pedimento}") + try: + # if pedimento.documents.filter(document_type=3).exists(): # Remesa ya descargada + # logger.info(f"Pedimento {pedimento.pedimento} ya tiene remesa descargada, omitiendo.") + # continue + pedimento_dict = pedimento_to_dict(pedimento) - credenciales = Vucem.objects.filter(id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id).first() + + credencial_importador = CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first() + if not credencial_importador: + logger.warning(f"Sin credenciales para RFC {pedimento.contribuyente} (pedimento {pedimento.pedimento}), omitiendo.") + continue + + credenciales = Vucem.objects.filter(id=credencial_importador.vucem.id).first() + if not credenciales: + logger.warning(f"Credencial Vucem no encontrada para pedimento {pedimento.pedimento}, omitiendo.") + continue credenciales_dict = credenciales_to_dict(credenciales) - + payload = { "pedimento": pedimento_dict, "credencial": credenciales_dict } - - + response = requests.post( - f"{SERVICE_API_URL_V2}/services/remesas", + f"{SERVICE_API_URL_V2}/services/remesas/", data=json.dumps(payload), headers={"Content-Type": "application/json"} ) - # Aquí puedes continuar con el resto de tu lógica + logger.info(f"Servicio enviado para pedimento {pedimento.pedimento} — status {response.status_code}") - print(f"Servicio enviado para pedimento {pedimento.pedimento}") + except Exception as e: + logger.error(f"Error procesando remesa para pedimento {pedimento.pedimento}: {e}", exc_info=True) @shared_task def procesar_coves(organizacion_id): @@ -522,6 +541,34 @@ def ejecutar_todos_por_organizacion(organizacion_id): procesar_pedimentos_completos.delay(organizacion_id) procesar_remesas.delay(organizacion_id) +def ejecutar_basicos_organizacion(organizacion_id): + # solo coves y e documents, si es necesario ya en un futuro se agregan los de partidas, pedimento completo y esas madres + procesar_coves.delay(organizacion_id) + procesar_acuse_coves.delay(organizacion_id) + procesar_edocs.delay(organizacion_id) + procesar_acuses.delay(organizacion_id) + # procesar_partidas.delay(organizacion_id) + # procesar_pedimentos_completos.delay(organizacion_id) + # procesar_remesas.delay(organizacion_id) +@shared_task +def process_organization_batch(org_id): + """ + Procesa todos los tipos de documentos pendientes para una organización. + """ + ejecutar_basicos_organizacion(org_id) +@shared_task +def process_all_organizations(): + """ + Envía una tarea por organización activa a la cola org_processing. + """ + active_orgs = Organizacion.objects.filter(is_active=True, is_verified=True) + + for org in active_orgs: + process_organization_batch.apply_async( + args=[org.id], + queue='org_processing' + ) + return f"Dispatched {active_orgs.count()} organizations" diff --git a/api/customs/views_auditor.py b/api/customs/views_auditor.py index 1ca1d32..d28a194 100644 --- a/api/customs/views_auditor.py +++ b/api/customs/views_auditor.py @@ -8,27 +8,24 @@ from drf_yasg import openapi from core.permissions import IsSuperUser, IsSameOrganizationDeveloper from .tasks.auditoria import ( crear_partidas, - crear_partidas_por_pedimento, - auditar_procesamiento_remesa_por_pedimento, auditar_coves, auditar_acuse_cove, auditar_edocuments, auditar_acuse, - auditar_cove_por_pedimento, - auditar_acuse_cove_por_pedimento, - auditar_edocument_por_pedimento, - auditar_acuse_por_pedimento + auditar_remesas, ) from .tasks.internal_services import auditar_pedimentos from .tasks.microservice_v2 import procesar_pedimentos_completos from api.customs.models import Pedimento from api.organization.models import Organizacion from api.record.models import Document -from .tasks.auditoria import auditar_pedimento_por_id from .tasks.auditoria_xml import extraer_info_pedimento_xml import tempfile import os from api.utils.storage_service import storage_service +import logging +import uuid +logger = logging.getLogger('api.customs.views_auditor') def get_document_content(documento): """ @@ -72,7 +69,7 @@ def get_document_path(documento): @swagger_auto_schema( method='post', - operation_description="Crea partidas para todos los pedimentos de una organización", + operation_description="Crea partidas faltantes para todos los pedimentos de una organización e informa cuáles están descargadas", request_body=openapi.Schema( type=openapi.TYPE_OBJECT, properties={ @@ -81,7 +78,7 @@ def get_document_path(documento): required=['organizacion_id'] ), responses={ - 200: openapi.Response('Tarea iniciada correctamente'), + 202: openapi.Response('Tarea iniciada — usar task_id para consultar resultado'), 400: openapi.Response('Error en los parámetros'), 403: openapi.Response('No tiene permisos suficientes') } @@ -89,37 +86,27 @@ def get_document_path(documento): @api_view(['POST']) @permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) def crear_partidas_organizacion(request): - """ - Crea partidas para todos los pedimentos de una organización específica. - """ organizacion_id = request.data.get('organizacion_id') if not organizacion_id: - return Response( - {'error': 'Debe proporcionar organizacion_id'}, - status=status.HTTP_400_BAD_REQUEST - ) + return Response({'error': 'Debe proporcionar organizacion_id'}, status=status.HTTP_400_BAD_REQUEST) - # Validar permisos user = request.user if not user.is_superuser and str(user.organizacion.id) != organizacion_id: - return Response( - {'error': 'No tiene permisos para esta organización'}, - status=status.HTTP_403_FORBIDDEN - ) + return Response({'error': 'No tiene permisos para esta organización'}, status=status.HTTP_403_FORBIDDEN) - # Ejecutar la tarea task = crear_partidas.delay(organizacion_id) - message = f"Creación de partidas iniciada para la organización {organizacion_id}" return Response({ - 'message': message, - 'task_id': task.id - }, status=status.HTTP_200_OK) + 'organizacion_id': organizacion_id, + 'auditoria': 'partidas', + 'task_id': task.id, + 'mensaje': f'Creación de partidas iniciada. Consulta el resultado en GET /api/tasks/status/{task.id}/', + }, status=status.HTTP_202_ACCEPTED) @swagger_auto_schema( method='post', - operation_description="Crea partidas para un pedimento específico", + operation_description="Crea partidas faltantes para un pedimento e informa cuáles están descargadas", request_body=openapi.Schema( type=openapi.TYPE_OBJECT, properties={ @@ -128,48 +115,74 @@ def crear_partidas_organizacion(request): required=['pedimento_id'] ), responses={ - 200: openapi.Response('Tarea iniciada correctamente'), + 200: openapi.Response('Resultado de creación y estado de descarga de partidas'), 400: openapi.Response('Error en los parámetros'), 403: openapi.Response('No tiene permisos suficientes'), 404: openapi.Response('Pedimento no encontrado') } ) @api_view(['POST']) -@permission_classes([IsAuthenticated ]) +@permission_classes([IsAuthenticated]) def crear_partidas_pedimento(request): - """ - Crea partidas para un pedimento específico. - """ pedimento_id = request.data.get('pedimento_id') if not pedimento_id: - return Response( - {'error': 'Debe proporcionar pedimento_id'}, - status=status.HTTP_400_BAD_REQUEST - ) + return Response({'error': 'Debe proporcionar pedimento_id'}, status=status.HTTP_400_BAD_REQUEST) - # Validar permisos y existencia del pedimento try: - pedimento = Pedimento.objects.get(id=pedimento_id) - user = request.user - if not user.is_superuser and str(pedimento.organizacion.id) != str(user.organizacion.id): - return Response( - {'error': 'No tiene permisos para este pedimento'}, - status=status.HTTP_403_FORBIDDEN - ) + pedimento = Pedimento.objects.prefetch_related('partidas').select_related('organizacion').get(id=pedimento_id) except Pedimento.DoesNotExist: - return Response( - {'error': 'Pedimento no encontrado'}, - status=status.HTTP_404_NOT_FOUND - ) + return Response({'error': 'Pedimento no encontrado'}, status=status.HTTP_404_NOT_FOUND) - # Ejecutar la tarea - task = crear_partidas_por_pedimento.delay(pedimento_id) - message = f"Creación de partidas iniciada para el pedimento {pedimento_id}" + user = request.user + if not user.is_superuser and str(pedimento.organizacion.id) != str(user.organizacion.id): + return Response({'error': 'No tiene permisos para este pedimento'}, status=status.HTTP_403_FORBIDDEN) + + if not pedimento.numero_partidas or pedimento.numero_partidas <= 0: + return Response({ + 'pedimento_id': str(pedimento_id), + 'pedimento': pedimento.pedimento, + 'estado': 'sin_datos', + 'mensaje': f'El pedimento no tiene número de partidas definido (numero_partidas={pedimento.numero_partidas})', + }, status=status.HTTP_200_OK) + + # Crear partidas faltantes (get_or_create por número) + from api.customs.models import Partida + partidas_creadas = 0 + for i in range(1, pedimento.numero_partidas + 1): + _, created = Partida.objects.get_or_create( + pedimento=pedimento, + numero_partida=i, + defaults={'organizacion_id': pedimento.organizacion_id} + ) + if created: + partidas_creadas += 1 + + # Evaluar estado de descarga sobre el conjunto completo + partidas = list(pedimento.partidas.order_by('numero_partida')) + total = len(partidas) + descargadas = [p.numero_partida for p in partidas if p.descargado] + no_descargadas = [p.numero_partida for p in partidas if not p.descargado] + + if not no_descargadas: + estado = 'completado' + mensaje = f'Todas las partidas están descargadas ({total}/{total})' + else: + estado = 'en_proceso' + mensaje = f'{len(no_descargadas)} de {total} partidas pendientes de descarga' return Response({ - 'message': message, - 'task_id': task.id + 'pedimento_id': str(pedimento_id), + 'pedimento': pedimento.pedimento, + 'estado': estado, + 'mensaje': mensaje, + 'resumen': { + 'total_partidas': total, + 'partidas_creadas_ahora': partidas_creadas, + 'descargadas': len(descargadas), + 'no_descargadas': len(no_descargadas), + }, + 'no_descargadas': no_descargadas, }, status=status.HTTP_200_OK) @swagger_auto_schema( @@ -223,7 +236,7 @@ def auditar_pedimentos_endpoint(request): @swagger_auto_schema( method='post', - operation_description="Audita el procesamiento de remesa para un pedimento específico", + operation_description="Audita el estado de procesamiento de remesa de un pedimento específico", request_body=openapi.Schema( type=openapi.TYPE_OBJECT, properties={ @@ -232,7 +245,7 @@ def auditar_pedimentos_endpoint(request): required=['pedimento_id'] ), responses={ - 200: openapi.Response('Tarea de auditoría iniciada correctamente'), + 200: openapi.Response('Estado de procesamiento de remesa del pedimento'), 400: openapi.Response('Error en los parámetros'), 403: openapi.Response('No tiene permisos suficientes'), 404: openapi.Response('Pedimento no encontrado') @@ -241,247 +254,179 @@ def auditar_pedimentos_endpoint(request): @api_view(['POST']) @permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) def auditar_procesamiento_remesa_pedimento_endpoint(request): - """ - Inicia una tarea de auditoría de remesa para un pedimento específico. - Verifica el estado del procesamiento de remesa y la creación de COVEs. - """ pedimento_id = request.data.get('pedimento_id') if not pedimento_id: - return Response( - {'error': 'Debe proporcionar pedimento_id'}, - status=status.HTTP_400_BAD_REQUEST - ) + return Response({'error': 'Debe proporcionar pedimento_id'}, status=status.HTTP_400_BAD_REQUEST) - # Validar permisos y existencia del pedimento try: - pedimento = Pedimento.objects.get(id=pedimento_id) - user = request.user - if not user.is_superuser and str(pedimento.organizacion.id) != str(user.organizacion.id): - return Response( - {'error': 'No tiene permisos para este pedimento'}, - status=status.HTTP_403_FORBIDDEN - ) + pedimento = Pedimento.objects.select_related('organizacion').prefetch_related('coves').get(id=pedimento_id) except Pedimento.DoesNotExist: - return Response( - {'error': 'Pedimento no encontrado'}, - status=status.HTTP_404_NOT_FOUND - ) + return Response({'error': 'Pedimento no encontrado'}, status=status.HTTP_404_NOT_FOUND) - # Ejecutar la tarea de auditoría - task = auditar_procesamiento_remesa_por_pedimento.delay(pedimento_id) - message = f"Auditoría de remesa iniciada para el pedimento {pedimento_id}" + user = request.user + if not user.is_superuser and str(pedimento.organizacion.id) != str(user.organizacion.id): + return Response({'error': 'No tiene permisos para este pedimento'}, status=status.HTTP_403_FORBIDDEN) + + if not pedimento.remesas: + return Response({ + 'pedimento_id': str(pedimento_id), + 'pedimento': pedimento.pedimento, + 'tiene_remesas': False, + 'estado': 'completado', + 'mensaje': 'El pedimento no tiene remesas para procesar', + }, status=status.HTTP_200_OK) + + tiene_documento_remesa = pedimento.documents.filter(document_type=3).exists() + coves = list(pedimento.coves.all()) + total_coves = len(coves) + + if not tiene_documento_remesa: + estado = 'en_proceso' + mensaje = 'Documento XML de remesa aún no descargado' + elif total_coves == 0: + estado = 'en_proceso' + mensaje = 'Documento de remesa disponible pero no se han creado COVEs' + else: + estado = 'completado' + mensaje = f'Remesa procesada — {total_coves} COVE(s) registrados' return Response({ - 'message': message, - 'task_id': task.id, - 'pedimento': { - 'id': str(pedimento.id), - 'pedimento': pedimento.pedimento, - 'tiene_remesas': pedimento.remesas - } + 'pedimento_id': str(pedimento_id), + 'pedimento': pedimento.pedimento, + 'tiene_remesas': True, + 'estado': estado, + 'mensaje': mensaje, + 'resumen': { + 'tiene_documento_remesa': tiene_documento_remesa, + 'total_coves_registrados': total_coves, + }, + 'coves': [c.numero_cove for c in coves], }, status=status.HTTP_200_OK) +def _lanzar_auditoria_organizacion(request, task_fn, label): + """Helper compartido para los 4 endpoints de auditoría masiva por organización.""" + organizacion_id = request.data.get('organizacion_id') + if not organizacion_id: + return Response({'error': 'Debe proporcionar organizacion_id'}, status=status.HTTP_400_BAD_REQUEST) + + user = request.user + if not user.is_superuser and str(user.organizacion.id) != organizacion_id: + return Response({'error': 'No tiene permisos para esta organización'}, status=status.HTTP_403_FORBIDDEN) + + task = task_fn.delay(organizacion_id) + return Response({ + 'organizacion_id': organizacion_id, + 'auditoria': label, + 'task_id': task.id, + 'mensaje': f'Auditoría de {label} iniciada. Consulta el resultado en GET /api/tasks/status/{task.id}/', + }, status=status.HTTP_202_ACCEPTED) + + @swagger_auto_schema( method='post', - operation_description="Audita los COVEs de una organización", + operation_description="Audita el estado de descarga de COVEs de todos los pedimentos de una organización", request_body=openapi.Schema( type=openapi.TYPE_OBJECT, - properties={ - 'organizacion_id': openapi.Schema(type=openapi.TYPE_STRING, description='ID de la organización') - }, + properties={'organizacion_id': openapi.Schema(type=openapi.TYPE_STRING)}, required=['organizacion_id'] ), responses={ - 200: openapi.Response('Tarea de auditoría iniciada correctamente'), + 202: openapi.Response('Tarea iniciada — usar task_id para consultar resultado'), 400: openapi.Response('Error en los parámetros'), - 403: openapi.Response('No tiene permisos suficientes') + 403: openapi.Response('No tiene permisos suficientes'), } ) @api_view(['POST']) @permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) def auditar_coves_endpoint(request): - """ - Inicia una tarea de auditoría para los COVEs de una organización. - Verifica la existencia y validez de los COVEs generados. - """ - organizacion_id = request.data.get('organizacion_id') - - if not organizacion_id: - return Response( - {'error': 'Debe proporcionar organizacion_id'}, - status=status.HTTP_400_BAD_REQUEST - ) - - # Validar permisos - user = request.user - if not user.is_superuser and str(user.organizacion.id) != organizacion_id: - return Response( - {'error': 'No tiene permisos para esta organización'}, - status=status.HTTP_403_FORBIDDEN - ) - - # Ejecutar la tarea de auditoría - task = auditar_coves.delay(organizacion_id) - message = f"Auditoría de COVEs iniciada para la organización {organizacion_id}" - - return Response({ - 'message': message, - 'task_id': task.id - }, status=status.HTTP_200_OK) + return _lanzar_auditoria_organizacion(request, auditar_coves, 'COVEs') @swagger_auto_schema( method='post', - operation_description="Audita los acuses de COVE de una organización", + operation_description="Audita el estado de descarga de acuses de COVE de todos los pedimentos de una organización", request_body=openapi.Schema( type=openapi.TYPE_OBJECT, - properties={ - 'organizacion_id': openapi.Schema(type=openapi.TYPE_STRING, description='ID de la organización') - }, + properties={'organizacion_id': openapi.Schema(type=openapi.TYPE_STRING)}, required=['organizacion_id'] ), responses={ - 200: openapi.Response('Tarea de auditoría iniciada correctamente'), + 202: openapi.Response('Tarea iniciada — usar task_id para consultar resultado'), 400: openapi.Response('Error en los parámetros'), - 403: openapi.Response('No tiene permisos suficientes') + 403: openapi.Response('No tiene permisos suficientes'), } ) @api_view(['POST']) @permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) def auditar_acuse_cove_endpoint(request): - """ - Inicia una tarea de auditoría para los acuses de COVE de una organización. - Verifica la recepción y validez de los acuses de COVE. - """ - organizacion_id = request.data.get('organizacion_id') - - if not organizacion_id: - return Response( - {'error': 'Debe proporcionar organizacion_id'}, - status=status.HTTP_400_BAD_REQUEST - ) - - # Validar permisos - user = request.user - if not user.is_superuser and str(user.organizacion.id) != organizacion_id: - return Response( - {'error': 'No tiene permisos para esta organización'}, - status=status.HTTP_403_FORBIDDEN - ) - - # Ejecutar la tarea de auditoría - task = auditar_acuse_cove.delay(organizacion_id) - message = f"Auditoría de acuses de COVE iniciada para la organización {organizacion_id}" - - return Response({ - 'message': message, - 'task_id': task.id - }, status=status.HTTP_200_OK) + return _lanzar_auditoria_organizacion(request, auditar_acuse_cove, 'acuses de COVE') @swagger_auto_schema( method='post', - operation_description="Audita los EDocuments de una organización", + operation_description="Audita el estado de descarga de EDocuments de todos los pedimentos de una organización", request_body=openapi.Schema( type=openapi.TYPE_OBJECT, - properties={ - 'organizacion_id': openapi.Schema(type=openapi.TYPE_STRING, description='ID de la organización') - }, + properties={'organizacion_id': openapi.Schema(type=openapi.TYPE_STRING)}, required=['organizacion_id'] ), responses={ - 200: openapi.Response('Tarea de auditoría iniciada correctamente'), + 202: openapi.Response('Tarea iniciada — usar task_id para consultar resultado'), 400: openapi.Response('Error en los parámetros'), - 403: openapi.Response('No tiene permisos suficientes') + 403: openapi.Response('No tiene permisos suficientes'), } ) @api_view(['POST']) @permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) def auditar_edocuments_endpoint(request): - """ - Inicia una tarea de auditoría para los EDocuments de una organización. - Verifica la existencia y validez de los EDocuments generados. - """ - organizacion_id = request.data.get('organizacion_id') - - if not organizacion_id: - return Response( - {'error': 'Debe proporcionar organizacion_id'}, - status=status.HTTP_400_BAD_REQUEST - ) - - # Validar permisos - user = request.user - if not user.is_superuser and str(user.organizacion.id) != organizacion_id: - return Response( - {'error': 'No tiene permisos para esta organización'}, - status=status.HTTP_403_FORBIDDEN - ) - - # Ejecutar la tarea de auditoría - task = auditar_edocuments.delay(organizacion_id) - message = f"Auditoría de EDocuments iniciada para la organización {organizacion_id}" - - return Response({ - 'message': message, - 'task_id': task.id - }, status=status.HTTP_200_OK) + return _lanzar_auditoria_organizacion(request, auditar_edocuments, 'EDocuments') @swagger_auto_schema( method='post', - operation_description="Audita los acuses de una organización", + operation_description="Audita el estado de descarga de acuses de EDocument de todos los pedimentos de una organización", request_body=openapi.Schema( type=openapi.TYPE_OBJECT, - properties={ - 'organizacion_id': openapi.Schema(type=openapi.TYPE_STRING, description='ID de la organización') - }, + properties={'organizacion_id': openapi.Schema(type=openapi.TYPE_STRING)}, required=['organizacion_id'] ), responses={ - 200: openapi.Response('Tarea de auditoría iniciada correctamente'), + 202: openapi.Response('Tarea iniciada — usar task_id para consultar resultado'), 400: openapi.Response('Error en los parámetros'), - 403: openapi.Response('No tiene permisos suficientes') + 403: openapi.Response('No tiene permisos suficientes'), } ) @api_view(['POST']) @permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) def auditar_acuse_endpoint(request): - """ - Inicia una tarea de auditoría para los acuses de una organización. - Verifica la recepción y validez de los acuses. - """ - organizacion_id = request.data.get('organizacion_id') - - if not organizacion_id: - return Response( - {'error': 'Debe proporcionar organizacion_id'}, - status=status.HTTP_400_BAD_REQUEST - ) - - # Validar permisos - user = request.user - if not user.is_superuser and str(user.organizacion.id) != organizacion_id: - return Response( - {'error': 'No tiene permisos para esta organización'}, - status=status.HTTP_403_FORBIDDEN - ) - - # Ejecutar la tarea de auditoría - task = auditar_acuse.delay(organizacion_id) - message = f"Auditoría de acuses iniciada para la organización {organizacion_id}" - - return Response({ - 'message': message, - 'task_id': task.id - }, status=status.HTTP_200_OK) + return _lanzar_auditoria_organizacion(request, auditar_acuse, 'acuses de EDocument') @swagger_auto_schema( method='post', - operation_description="Audita el COVE de un pedimento específico", + operation_description="Audita el estado de descarga de remesas de todos los pedimentos de una organización", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={'organizacion_id': openapi.Schema(type=openapi.TYPE_STRING)}, + required=['organizacion_id'] + ), + responses={ + 202: openapi.Response('Tarea iniciada — usar task_id para consultar resultado'), + 400: openapi.Response('Error en los parámetros'), + 403: openapi.Response('No tiene permisos suficientes'), + } +) +@api_view(['POST']) +@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) +def auditar_remesas_endpoint(request): + return _lanzar_auditoria_organizacion(request, auditar_remesas, 'remesas') + + +@swagger_auto_schema( + method='post', + operation_description="Audita el estado de descarga de COVEs de un pedimento específico", request_body=openapi.Schema( type=openapi.TYPE_OBJECT, properties={ @@ -490,7 +435,7 @@ def auditar_acuse_endpoint(request): required=['pedimento_id'] ), responses={ - 200: openapi.Response('Tarea de auditoría iniciada correctamente'), + 200: openapi.Response('Estado de descarga de COVEs del pedimento'), 400: openapi.Response('Error en los parámetros'), 403: openapi.Response('No tiene permisos suficientes'), 404: openapi.Response('Pedimento no encontrado') @@ -502,19 +447,48 @@ def auditar_cove_pedimento_endpoint(request): pedimento_id = request.data.get('pedimento_id') if not pedimento_id: return Response({'error': 'Debe proporcionar pedimento_id'}, status=status.HTTP_400_BAD_REQUEST) + try: - pedimento = Pedimento.objects.get(id=pedimento_id) - user = request.user - if not user.is_superuser and str(pedimento.organizacion.id) != str(user.organizacion.id): - return Response({'error': 'No tiene permisos para este pedimento'}, status=status.HTTP_403_FORBIDDEN) + pedimento = Pedimento.objects.select_related('organizacion').prefetch_related('coves').get(id=pedimento_id) except Pedimento.DoesNotExist: return Response({'error': 'Pedimento no encontrado'}, status=status.HTTP_404_NOT_FOUND) - task = auditar_cove_por_pedimento.delay(pedimento_id) - return Response({'message': f'Auditoría de COVE iniciada para el pedimento {pedimento_id}', 'task_id': task.id}, status=status.HTTP_200_OK) + + user = request.user + if not user.is_superuser and str(pedimento.organizacion.id) != str(user.organizacion.id): + return Response({'error': 'No tiene permisos para este pedimento'}, status=status.HTTP_403_FORBIDDEN) + + coves = list(pedimento.coves.all()) + total = len(coves) + descargados = sum(1 for c in coves if c.cove_descargado) + pendientes = [c.numero_cove for c in coves if not c.cove_descargado] + + if total == 0: + nuevo_estado = 3 + mensaje = 'El pedimento no tiene COVEs registrados' + elif descargados == total: + nuevo_estado = 3 + mensaje = 'Todos los COVEs están descargados' + else: + nuevo_estado = 4 + mensaje = f'{total - descargados} de {total} COVEs pendientes de descarga' + + from api.customs.tasks.auditoria import modificar_estado_procesamiento + modificar_estado_procesamiento(pedimento, servicio_id=8, nuevo_estado=nuevo_estado) + + return Response({ + 'pedimento_id': str(pedimento_id), + 'estado': 'completado' if nuevo_estado == 3 else 'en_proceso', + 'mensaje': mensaje, + 'resumen': { + 'total_coves': total, + 'coves_descargados': descargados, + }, + 'pendientes': pendientes, + }, status=status.HTTP_200_OK) @swagger_auto_schema( method='post', - operation_description="Audita el acuse de COVE de un pedimento específico", + operation_description="Audita el estado de descarga de acuses de COVE de un pedimento específico", request_body=openapi.Schema( type=openapi.TYPE_OBJECT, properties={ @@ -523,7 +497,7 @@ def auditar_cove_pedimento_endpoint(request): required=['pedimento_id'] ), responses={ - 200: openapi.Response('Tarea de auditoría iniciada correctamente'), + 200: openapi.Response('Estado de descarga de acuses de COVE del pedimento'), 400: openapi.Response('Error en los parámetros'), 403: openapi.Response('No tiene permisos suficientes'), 404: openapi.Response('Pedimento no encontrado') @@ -535,19 +509,48 @@ def auditar_acuse_cove_pedimento_endpoint(request): pedimento_id = request.data.get('pedimento_id') if not pedimento_id: return Response({'error': 'Debe proporcionar pedimento_id'}, status=status.HTTP_400_BAD_REQUEST) + try: - pedimento = Pedimento.objects.get(id=pedimento_id) - user = request.user - if not user.is_superuser and str(pedimento.organizacion.id) != str(user.organizacion.id): - return Response({'error': 'No tiene permisos para este pedimento'}, status=status.HTTP_403_FORBIDDEN) + pedimento = Pedimento.objects.select_related('organizacion').prefetch_related('coves').get(id=pedimento_id) except Pedimento.DoesNotExist: return Response({'error': 'Pedimento no encontrado'}, status=status.HTTP_404_NOT_FOUND) - task = auditar_acuse_cove_por_pedimento.delay(pedimento_id) - return Response({'message': f'Auditoría de acuse de COVE iniciada para el pedimento {pedimento_id}', 'task_id': task.id}, status=status.HTTP_200_OK) + + user = request.user + if not user.is_superuser and str(pedimento.organizacion.id) != str(user.organizacion.id): + return Response({'error': 'No tiene permisos para este pedimento'}, status=status.HTTP_403_FORBIDDEN) + + coves = list(pedimento.coves.all()) + total = len(coves) + descargados = sum(1 for c in coves if c.acuse_cove_descargado) + pendientes = [c.numero_cove for c in coves if not c.acuse_cove_descargado] + + if total == 0: + nuevo_estado = 3 + mensaje = 'El pedimento no tiene COVEs registrados, no hay acuses que auditar' + elif descargados == total: + nuevo_estado = 3 + mensaje = 'Todos los acuses de COVE están descargados' + else: + nuevo_estado = 4 + mensaje = f'{total - descargados} de {total} acuses de COVE pendientes de descarga' + + from api.customs.tasks.auditoria import modificar_estado_procesamiento + modificar_estado_procesamiento(pedimento, servicio_id=9, nuevo_estado=nuevo_estado) + + return Response({ + 'pedimento_id': str(pedimento_id), + 'estado': 'completado' if nuevo_estado == 3 else 'en_proceso', + 'mensaje': mensaje, + 'resumen': { + 'total_coves': total, + 'acuses_descargados': descargados, + }, + 'pendientes': pendientes, + }, status=status.HTTP_200_OK) @swagger_auto_schema( method='post', - operation_description="Audita el EDocument de un pedimento específico", + operation_description="Audita el estado de descarga de EDocuments de un pedimento específico", request_body=openapi.Schema( type=openapi.TYPE_OBJECT, properties={ @@ -556,7 +559,7 @@ def auditar_acuse_cove_pedimento_endpoint(request): required=['pedimento_id'] ), responses={ - 200: openapi.Response('Tarea de auditoría iniciada correctamente'), + 200: openapi.Response('Estado de descarga de EDocuments del pedimento'), 400: openapi.Response('Error en los parámetros'), 403: openapi.Response('No tiene permisos suficientes'), 404: openapi.Response('Pedimento no encontrado') @@ -568,19 +571,48 @@ def auditar_edocument_pedimento_endpoint(request): pedimento_id = request.data.get('pedimento_id') if not pedimento_id: return Response({'error': 'Debe proporcionar pedimento_id'}, status=status.HTTP_400_BAD_REQUEST) + try: - pedimento = Pedimento.objects.get(id=pedimento_id) - user = request.user - if not user.is_superuser and str(pedimento.organizacion.id) != str(user.organizacion.id): - return Response({'error': 'No tiene permisos para este pedimento'}, status=status.HTTP_403_FORBIDDEN) + pedimento = Pedimento.objects.select_related('organizacion').prefetch_related('documentos').get(id=pedimento_id) except Pedimento.DoesNotExist: return Response({'error': 'Pedimento no encontrado'}, status=status.HTTP_404_NOT_FOUND) - task = auditar_edocument_por_pedimento.delay(pedimento_id) - return Response({'message': f'Auditoría de EDocument iniciada para el pedimento {pedimento_id}', 'task_id': task.id}, status=status.HTTP_200_OK) + + user = request.user + if not user.is_superuser and str(pedimento.organizacion.id) != str(user.organizacion.id): + return Response({'error': 'No tiene permisos para este pedimento'}, status=status.HTTP_403_FORBIDDEN) + + edocuments = list(pedimento.documentos.all()) + total = len(edocuments) + descargados = sum(1 for d in edocuments if d.edocument_descargado) + pendientes = [d.numero_edocument for d in edocuments if not d.edocument_descargado] + + if total == 0: + nuevo_estado = 3 + mensaje = 'El pedimento no tiene EDocuments registrados' + elif descargados == total: + nuevo_estado = 3 + mensaje = 'Todos los EDocuments están descargados' + else: + nuevo_estado = 4 + mensaje = f'{total - descargados} de {total} EDocuments pendientes de descarga' + + from api.customs.tasks.auditoria import modificar_estado_procesamiento + modificar_estado_procesamiento(pedimento, servicio_id=7, nuevo_estado=nuevo_estado) + + return Response({ + 'pedimento_id': str(pedimento_id), + 'estado': 'completado' if nuevo_estado == 3 else 'en_proceso', + 'mensaje': mensaje, + 'resumen': { + 'total_edocuments': total, + 'edocuments_descargados': descargados, + }, + 'pendientes': pendientes, + }, status=status.HTTP_200_OK) @swagger_auto_schema( method='post', - operation_description="Audita el acuse de un pedimento específico", + operation_description="Audita el estado de descarga de acuses de EDocument de un pedimento específico", request_body=openapi.Schema( type=openapi.TYPE_OBJECT, properties={ @@ -589,28 +621,56 @@ def auditar_edocument_pedimento_endpoint(request): required=['pedimento_id'] ), responses={ - 200: openapi.Response('Tarea de auditoría iniciada correctamente'), + 200: openapi.Response('Estado de descarga de acuses de EDocument del pedimento'), 400: openapi.Response('Error en los parámetros'), 403: openapi.Response('No tiene permisos suficientes'), 404: openapi.Response('Pedimento no encontrado') } ) - @api_view(['POST']) @permission_classes([IsAuthenticated]) def auditar_acuse_pedimento_endpoint(request): pedimento_id = request.data.get('pedimento_id') if not pedimento_id: return Response({'error': 'Debe proporcionar pedimento_id'}, status=status.HTTP_400_BAD_REQUEST) + try: - pedimento = Pedimento.objects.get(id=pedimento_id) - user = request.user - if not user.is_superuser and str(pedimento.organizacion.id) != str(user.organizacion.id): - return Response({'error': 'No tiene permisos para este pedimento'}, status=status.HTTP_403_FORBIDDEN) + pedimento = Pedimento.objects.select_related('organizacion').prefetch_related('documentos').get(id=pedimento_id) except Pedimento.DoesNotExist: return Response({'error': 'Pedimento no encontrado'}, status=status.HTTP_404_NOT_FOUND) - task = auditar_acuse_por_pedimento.delay(pedimento_id) - return Response({'message': f'Auditoría de acuse iniciada para el pedimento {pedimento_id}', 'task_id': task.id}, status=status.HTTP_200_OK) + + user = request.user + if not user.is_superuser and str(pedimento.organizacion.id) != str(user.organizacion.id): + return Response({'error': 'No tiene permisos para este pedimento'}, status=status.HTTP_403_FORBIDDEN) + + edocuments = list(pedimento.documentos.all()) + total = len(edocuments) + descargados = sum(1 for d in edocuments if d.acuse_descargado) + pendientes = [d.numero_edocument for d in edocuments if not d.acuse_descargado] + + if total == 0: + nuevo_estado = 3 + mensaje = 'El pedimento no tiene EDocuments registrados, no hay acuses que auditar' + elif descargados == total: + nuevo_estado = 3 + mensaje = 'Todos los acuses de EDocument están descargados' + else: + nuevo_estado = 4 + mensaje = f'{total - descargados} de {total} acuses de EDocument pendientes de descarga' + + from api.customs.tasks.auditoria import modificar_estado_procesamiento + modificar_estado_procesamiento(pedimento, servicio_id=6, nuevo_estado=nuevo_estado) + + return Response({ + 'pedimento_id': str(pedimento_id), + 'estado': 'completado' if nuevo_estado == 3 else 'en_proceso', + 'mensaje': mensaje, + 'resumen': { + 'total_edocuments': total, + 'acuses_descargados': descargados, + }, + 'pendientes': pendientes, + }, status=status.HTTP_200_OK) ### Procesamiento de pedimentos ### @swagger_auto_schema( @@ -1663,6 +1723,10 @@ def auditar_pedimento_endpoint(request): informacion_extraida = [] for documento in documentos_xml: + + print(f"documento >>>> {documento}") + logger.info(f"documento >>>> {documento}") + try: xml_info = { 'documento_id': str(documento.id), @@ -1695,9 +1759,6 @@ def auditar_pedimento_endpoint(request): 'error': f'Error procesando archivo: {str(e)}' }) - # Ejecutar la tarea de auditoría completa - task = auditar_pedimento_por_id.delay(pedimento_id) - response_data = { 'pedimento_id': str(pedimento_id), 'pedimento': pedimento.pedimento, @@ -1706,7 +1767,6 @@ def auditar_pedimento_endpoint(request): 'xmls_analizados': xmls_analizados, 'informacion_extraida': informacion_extraida, 'auditoria_completa': True, - 'task_id': task.id, 'mensaje': f'Auditoría completada para el pedimento {pedimento.pedimento}' } diff --git a/api/record/tests.py b/api/record/tests.py index 0c920b6..f28e411 100644 --- a/api/record/tests.py +++ b/api/record/tests.py @@ -1,12 +1,16 @@ from django.urls import reverse +from django.test import TestCase from rest_framework.test import APITestCase, APIClient from rest_framework import status from django.core.files.uploadedfile import SimpleUploadedFile +from unittest.mock import patch, MagicMock from api.organization.models import Organizacion, UsoAlmacenamiento from api.cuser.models import CustomUser from api.customs.models import Pedimento -from .models import Document +from api.licence.models import Licencia +from api.customs.views import is_same_document, get_clean_base_filename +from .models import Document, DocumentType import io class DocumentViewSetTests(APITestCase): @@ -95,3 +99,177 @@ class DocumentViewSetTests(APITestCase): url = reverse('descargar-documento', args=[doc.id]) response = self.client.get(url) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + +# --------------------------------------------------------------------------- +# Tests unitarios para las funciones helper de comparación de documentos +# --------------------------------------------------------------------------- + +class DocumentNameHelperTests(TestCase): + """Verifica que get_clean_base_filename e is_same_document manejan + correctamente el sufijo UUID de 8 chars que añade storage_service.""" + + def test_strips_uuid_suffix(self): + self.assertEqual(get_clean_base_filename('informe_a1b2c3d4.pdf'), 'informe') + + def test_no_suffix_unchanged(self): + self.assertEqual(get_clean_base_filename('informe.pdf'), 'informe') + + def test_is_same_document_matches_stored_uuid_name(self): + """El archivo guardado tiene sufijo, el nuevo no — deben coincidir.""" + doc = MagicMock() + doc.archivo.name = 'org_1/documents/24-01-3420-1234567/informe_a1b2c3d4.pdf' + doc.extension = 'pdf' + self.assertTrue(is_same_document(doc, 'informe.pdf')) + + def test_is_same_document_different_name_no_match(self): + doc = MagicMock() + doc.archivo.name = 'org_1/documents/ped/informe_a1b2c3d4.pdf' + doc.extension = 'pdf' + self.assertFalse(is_same_document(doc, 'otro.pdf')) + + def test_is_same_document_different_extension_no_match(self): + doc = MagicMock() + doc.archivo.name = 'org_1/documents/ped/informe_a1b2c3d4.pdf' + doc.extension = 'pdf' + self.assertFalse(is_same_document(doc, 'informe.xml')) + + def test_both_clean_names_equal(self): + """Dos archivos con UUID distintos pero mismo nombre base deben coincidir.""" + doc = MagicMock() + doc.archivo.name = 'org_1/documents/ped/pedimento_a1b2c3d4.xml' + doc.extension = 'xml' + self.assertTrue(is_same_document(doc, 'pedimento_b5c6d7e8.xml')) + + +# --------------------------------------------------------------------------- +# Tests de integración para bulk-upload (DocumentViewSet.bulk_upload) +# --------------------------------------------------------------------------- + +class BulkUploadReplaceTests(APITestCase): + """Verifica que bulk-upload reemplaza documentos existentes en vez de duplicar + y que no quedan archivos residuales en el storage.""" + + def setUp(self): + self.licencia = Licencia.objects.create(nombre="Lic100GB", almacenamiento=100) + self.org = Organizacion.objects.create( + nombre="OrgBulkUpload", + licencia=self.licencia, + is_active=True, + is_verified=True, + ) + self.user = CustomUser.objects.create_user( + username="bulkuploaduser", password="pass", organizacion=self.org + ) + self.pedimento = Pedimento.objects.create( + organizacion=self.org, + pedimento="1234567", + pedimento_app="24-01-3420-1234567", + ) + self.doc_type = DocumentType.objects.get_or_create(nombre="Documento General")[0] + self.url = reverse("Document-bulk-upload") + self.client.force_authenticate(user=self.user) + + def _post_file(self, filename, content=b"contenido de prueba"): + archivo = SimpleUploadedFile(filename, content, content_type="application/pdf") + return self.client.post( + self.url, + {"pedimento_id": str(self.pedimento.id), "files": [archivo]}, + format="multipart", + ) + + @patch("api.record.views.storage_service") + def test_new_file_creates_document(self, mock_st): + """Subir un archivo nuevo crea exactamente un Document.""" + mock_st.save_document.return_value = "org_1/documents/ped/informe_a1b2c3d4.pdf" + + response = self._post_file("informe.pdf") + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Document.objects.filter(pedimento=self.pedimento).count(), 1) + mock_st.delete_file.assert_not_called() + + @patch("api.record.views.storage_service") + def test_duplicate_replaces_not_creates(self, mock_st): + """Re-subir el mismo archivo debe actualizar el Document existente, + no crear uno nuevo.""" + old_path = "org_1/documents/24-01-3420-1234567/informe_a1b2c3d4.pdf" + old_doc = Document.objects.create( + organizacion=self.org, + pedimento=self.pedimento, + document_type=self.doc_type, + archivo=old_path, + size=500, + extension="pdf", + ) + new_path = "org_1/documents/24-01-3420-1234567/informe_b5c6d7e8.pdf" + mock_st.save_document.return_value = new_path + mock_st.delete_file.return_value = True + + response = self._post_file("informe.pdf", b"contenido actualizado") + + self.assertIn(response.status_code, [status.HTTP_201_CREATED, status.HTTP_207_MULTI_STATUS]) + docs = Document.objects.filter(pedimento=self.pedimento) + # Un único Document — sin duplicados + self.assertEqual(docs.count(), 1) + # Es el mismo registro (mismo UUID) + self.assertEqual(docs.first().id, old_doc.id) + # El campo archivo fue actualizado + old_doc.refresh_from_db() + self.assertEqual(old_doc.archivo.name, new_path) + + @patch("api.record.views.storage_service") + def test_replace_deletes_old_storage_file(self, mock_st): + """Al reemplazar, delete_file debe llamarse con la ruta del archivo viejo.""" + old_path = "org_1/documents/24-01-3420-1234567/informe_a1b2c3d4.pdf" + Document.objects.create( + organizacion=self.org, + pedimento=self.pedimento, + document_type=self.doc_type, + archivo=old_path, + size=500, + extension="pdf", + ) + mock_st.save_document.return_value = "org_1/documents/24-01-3420-1234567/informe_b5c6d7e8.pdf" + mock_st.delete_file.return_value = True + + self._post_file("informe.pdf") + + mock_st.delete_file.assert_called_once_with(old_path) + + @patch("api.record.views.storage_service") + def test_different_filename_creates_new_document(self, mock_st): + """Archivo con nombre diferente debe crear un Document adicional.""" + Document.objects.create( + organizacion=self.org, + pedimento=self.pedimento, + document_type=self.doc_type, + archivo="org_1/documents/ped/informe_a1b2c3d4.pdf", + size=500, + extension="pdf", + ) + mock_st.save_document.return_value = "org_1/documents/ped/otro_b5c6d7e8.pdf" + + self._post_file("otro.pdf") + + self.assertEqual(Document.objects.filter(pedimento=self.pedimento).count(), 2) + mock_st.delete_file.assert_not_called() + + @patch("api.record.views.storage_service") + def test_multiple_files_no_cross_replacement(self, mock_st): + """Subir dos archivos distintos en la misma petición crea dos Documents.""" + mock_st.save_document.side_effect = [ + "org_1/documents/ped/a_a1b2c3d4.pdf", + "org_1/documents/ped/b_a1b2c3d4.pdf", + ] + archivos = [ + SimpleUploadedFile("a.pdf", b"contenido a", content_type="application/pdf"), + SimpleUploadedFile("b.pdf", b"contenido b", content_type="application/pdf"), + ] + self.client.post( + self.url, + {"pedimento_id": str(self.pedimento.id), "files": archivos}, + format="multipart", + ) + self.assertEqual(Document.objects.filter(pedimento=self.pedimento).count(), 2) + mock_st.delete_file.assert_not_called()