From 94846fec8ab795ae2f0e66e933ccff421e6404f8 Mon Sep 17 00:00:00 2001 From: marcos Date: Mon, 25 May 2026 14:52:06 -0600 Subject: [PATCH] fix/forzar-carga-acuses --- api/cuser/serializers.py | 13 +- api/customs/serializers.py | 10 +- api/customs/tasks/microservice_v2.py | 299 ++++++++++++++-------- api/customs/urls.py | 2 + api/customs/views.py | 83 ++++++ api/customs/views_auditor.py | 360 +++++++++++++++++++++------ 6 files changed, 581 insertions(+), 186 deletions(-) diff --git a/api/cuser/serializers.py b/api/cuser/serializers.py index 4fe722b..e44812a 100644 --- a/api/cuser/serializers.py +++ b/api/cuser/serializers.py @@ -9,7 +9,7 @@ class CustomUserSerializer(serializers.ModelSerializer): Serializer for the CustomUser model. """ - password = serializers.CharField(write_only=True) + password = serializers.CharField(write_only=True, required=False) groups = serializers.PrimaryKeyRelatedField(queryset=Group.objects.all(), many=True, required=False) rfc = serializers.PrimaryKeyRelatedField( queryset=Importador.objects.all(), @@ -23,6 +23,17 @@ class CustomUserSerializer(serializers.ModelSerializer): fields = ['id', 'username', 'email', 'first_name', 'last_name', 'password', 'profile_picture', 'organizacion', 'is_importador', 'rfc', 'is_active', 'is_superuser', 'groups'] read_only_fields = ['id', 'organizacion', 'is_superuser'] + def validate_password(self, value): + if not value or not value.strip(): + raise serializers.ValidationError("La contraseña no puede estar vacía o contener solo espacios.") + return value + + def validate(self, attrs): + # En create, la contraseña es obligatoria + if self.instance is None and not attrs.get('password'): + raise serializers.ValidationError({"password": "Este campo es requerido."}) + return attrs + def create(self, validated_data): groups = validated_data.pop('groups', []) rfcs = validated_data.pop('rfc', []) diff --git a/api/customs/serializers.py b/api/customs/serializers.py index d43f0f4..c0bb127 100644 --- a/api/customs/serializers.py +++ b/api/customs/serializers.py @@ -184,7 +184,7 @@ 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 + # excluir solo request (21, 25); errores (22, 26) se incluyen para detección en frontend qs = Document.objects.filter( pedimento=obj.pedimento, archivo__icontains=numero, @@ -240,15 +240,11 @@ 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 + # Excluir solo request (19, 23); errores (20, 24) se incluyen para detección en frontend qs = Document.objects.filter( pedimento=obj.pedimento, archivo__icontains=numero, - ).exclude(document_type_id__in=[20, 24, 23, 19]) + ).exclude(document_type_id__in=[19, 23]) # Filtro por organización si aplica if hasattr(obj, 'organizacion') and obj.organizacion: diff --git a/api/customs/tasks/microservice_v2.py b/api/customs/tasks/microservice_v2.py index c094c8c..1ff99a8 100644 --- a/api/customs/tasks/microservice_v2.py +++ b/api/customs/tasks/microservice_v2.py @@ -91,12 +91,18 @@ def procesar_coves_pedimento(pedimento_id): "credencial": credenciales_dict } - response = requests.post( - f"{SERVICE_API_URL_V2}/services/all/coves", - data=json.dumps(payload), - headers={"Content-Type": "application/json"} - ) - print(f"Servicio de COVEs enviado para pedimento {pedimento.pedimento}") + try: + response = requests.post( + f"{SERVICE_API_URL_V2}/services/all/coves", + data=json.dumps(payload), + headers={"Content-Type": "application/json"}, + timeout=60 + ) + response.raise_for_status() + logging.info(f"COVEs encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}") + except requests.exceptions.RequestException as e: + logging.error(f"Error encolando COVEs para pedimento {pedimento.pedimento}: {e}") + raise @shared_task def procesar_acuse_coves_pedimento(pedimento_id): @@ -107,19 +113,25 @@ def procesar_acuse_coves_pedimento(pedimento_id): id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id ).first() credenciales_dict = credenciales_to_dict(credenciales) - + payload = { "coves": [cove_to_dict(cove) for cove in pedimento.coves.filter(acuse_cove_descargado=False)], "pedimento": pedimento_dict, "credencial": credenciales_dict } - - response = requests.post( - f"{SERVICE_API_URL_V2}/services/all/acuse/cove/", - data=json.dumps(payload), - headers={"Content-Type": "application/json"} - ) - print(f"Servicio de acuses de COVEs enviado para pedimento {pedimento.pedimento}") + + try: + response = requests.post( + f"{SERVICE_API_URL_V2}/services/all/acuse/cove/", + data=json.dumps(payload), + headers={"Content-Type": "application/json"}, + timeout=60 + ) + response.raise_for_status() + logging.info(f"Acuses de COVEs encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}") + except requests.exceptions.RequestException as e: + logging.error(f"Error encolando acuses de COVEs para pedimento {pedimento.pedimento}: {e}") + raise @shared_task def procesar_edocs_pedimento(pedimento_id): @@ -130,19 +142,25 @@ def procesar_edocs_pedimento(pedimento_id): id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id ).first() credenciales_dict = credenciales_to_dict(credenciales) - + payload = { "edocs": [edoc_to_dict(edoc) for edoc in pedimento.documentos.filter(edocument_descargado=False)], "pedimento": pedimento_dict, "credencial": credenciales_dict } - response = requests.post( - f"{SERVICE_API_URL_V2}/services/download/all/edocs/", - data=json.dumps(payload), - headers={"Content-Type": "application/json"} - ) - print(f"Servicio de E-documents enviado para pedimento {pedimento.pedimento}") + try: + response = requests.post( + f"{SERVICE_API_URL_V2}/services/download/all/edocs/", + data=json.dumps(payload), + headers={"Content-Type": "application/json"}, + timeout=60 + ) + response.raise_for_status() + logging.info(f"E-documents encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}") + except requests.exceptions.RequestException as e: + logging.error(f"Error encolando E-documents para pedimento {pedimento.pedimento}: {e}") + raise @shared_task def procesar_acuses_pedimento(pedimento_id): @@ -153,19 +171,25 @@ def procesar_acuses_pedimento(pedimento_id): id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id ).first() credenciales_dict = credenciales_to_dict(credenciales) - + payload = { "edocs": [edoc_to_dict(edoc) for edoc in pedimento.documentos.filter(acuse_descargado=False)], "pedimento": pedimento_dict, "credencial": credenciales_dict } - response = requests.post( - f"{SERVICE_API_URL_V2}/services/all/acuse/pedimento/", - data=json.dumps(payload), - headers={"Content-Type": "application/json"} - ) - print(f"Servicio de acuses enviado para pedimento {pedimento.pedimento}") + try: + response = requests.post( + f"{SERVICE_API_URL_V2}/services/all/acuse/pedimento/", + data=json.dumps(payload), + headers={"Content-Type": "application/json"}, + timeout=60 + ) + response.raise_for_status() + logging.info(f"Acuses encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}") + except requests.exceptions.RequestException as e: + logging.error(f"Error encolando acuses para pedimento {pedimento.pedimento}: {e}") + raise @shared_task def procesar_partidas_pedimento(pedimento_id): @@ -176,19 +200,32 @@ def procesar_partidas_pedimento(pedimento_id): id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id ).first() credenciales_dict = credenciales_to_dict(credenciales) - + + partidas_pendientes = list(pedimento.partidas.filter(descargado=False)) payload = { - "partidas": [partida_to_dict(partida) for partida in pedimento.partidas.filter(descargado=False)], + "partidas": [partida_to_dict(p) for p in partidas_pendientes], "pedimento": pedimento_dict, "credencial": credenciales_dict } - response = requests.post( - f"{SERVICE_API_URL_V2}/services/all/partidas/", - data=json.dumps(payload), - headers={"Content-Type": "application/json"} - ) - print(f"Servicio de partidas enviado para pedimento {pedimento.pedimento}") + try: + response = requests.post( + f"{SERVICE_API_URL_V2}/services/all/partidas/", + data=json.dumps(payload), + headers={"Content-Type": "application/json"}, + timeout=60 + ) + response.raise_for_status() + result = response.json() + logging.info( + f"Partidas encoladas para pedimento {pedimento.pedimento}: " + f"{result.get('total', 0)} de {len(partidas_pendientes)}" + ) + except requests.exceptions.RequestException as e: + logging.error( + f"Error encolando partidas para pedimento {pedimento.pedimento}: {e}" + ) + raise @shared_task def procesar_remesas_pedimento(pedimento_id): @@ -205,12 +242,18 @@ def procesar_remesas_pedimento(pedimento_id): "credencial": credenciales_dict } - response = requests.post( - f"{SERVICE_API_URL_V2}/services/remesas", - data=json.dumps(payload), - headers={"Content-Type": "application/json"} - ) - print(f"Servicio de remesas enviado para pedimento {pedimento.pedimento}") + try: + response = requests.post( + f"{SERVICE_API_URL_V2}/services/remesas", + data=json.dumps(payload), + headers={"Content-Type": "application/json"}, + timeout=60 + ) + response.raise_for_status() + logging.info(f"Remesa encolada para pedimento {pedimento.pedimento}") + except requests.exceptions.RequestException as e: + logging.error(f"Error encolando remesa para pedimento {pedimento.pedimento}: {e}") + raise @shared_task def procesar_pedimento_completo_individual(pedimento_id): @@ -225,13 +268,19 @@ def procesar_pedimento_completo_individual(pedimento_id): "pedimento": pedimento_dict, "credencial": credenciales_dict } - response = requests.post( - f"{SERVICE_API_URL_V2}/services/pedimento_completo", - data=json.dumps(payload), - headers={"Content-Type": "application/json"} - ) - print(f"Servicio enviado para pedimento {pedimento.pedimento}") - return response + try: + response = requests.post( + f"{SERVICE_API_URL_V2}/services/pedimento_completo", + data=json.dumps(payload), + headers={"Content-Type": "application/json"}, + timeout=60 + ) + response.raise_for_status() + logging.info(f"Pedimento completo encolado: {pedimento.pedimento}") + return response + except requests.exceptions.RequestException as e: + logging.error(f"Error encolando pedimento completo {pedimento.pedimento}: {e}") + raise @shared_task def procesar_pedimentos_completos(organizacion_id): @@ -270,13 +319,18 @@ def procesar_pedimentos_completos(organizacion_id): url = f"{SERVICE_API_URL_V2}/services/pedimento_completo" dataJson = json.dumps(payload) - response = requests.post( - url, - data=dataJson, - headers={"Content-Type": "application/json"} - ) - # Aquí puedes continuar con el resto de tu lógica - print(f"Servicio enviado para pedimento {pedimento.pedimento}") + try: + response = requests.post( + url, + data=dataJson, + headers={"Content-Type": "application/json"}, + timeout=60 + ) + response.raise_for_status() + logging.info(f"Pedimento completo encolado: {pedimento.pedimento}") + except requests.exceptions.RequestException as e: + logging.error(f"Error encolando pedimento completo {pedimento.pedimento}: {e}") + continue @shared_task def procesar_remesas(organizacion_id): @@ -311,9 +365,11 @@ def procesar_remesas(organizacion_id): response = requests.post( f"{SERVICE_API_URL_V2}/services/remesas/", data=json.dumps(payload), - headers={"Content-Type": "application/json"} + headers={"Content-Type": "application/json"}, + timeout=60 ) - logger.info(f"Servicio enviado para pedimento {pedimento.pedimento} — status {response.status_code}") + response.raise_for_status() + logger.info(f"Remesa encolada para pedimento {pedimento.pedimento} — status {response.status_code}") except Exception as e: logger.error(f"Error procesando remesa para pedimento {pedimento.pedimento}: {e}", exc_info=True) @@ -339,14 +395,18 @@ def procesar_coves(organizacion_id): "credencial": credenciales_dict } - response = requests.post( - f"{SERVICE_API_URL_V2}/services/all/coves", - data=json.dumps(payload), - headers={"Content-Type": "application/json"} - ) - # Aquí puedes continuar con el resto de tu lógica - - print(f"Servicio enviado para pedimento {pedimento.pedimento}") + try: + response = requests.post( + f"{SERVICE_API_URL_V2}/services/all/coves", + data=json.dumps(payload), + headers={"Content-Type": "application/json"}, + timeout=60 + ) + response.raise_for_status() + logging.info(f"COVEs encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}") + except requests.exceptions.RequestException as e: + logging.error(f"Error encolando COVEs para pedimento {pedimento.pedimento}: {e}") + continue @shared_task def procesar_acuse_coves(organizacion_id): @@ -370,14 +430,18 @@ def procesar_acuse_coves(organizacion_id): "credencial": credenciales_dict } - response = requests.post( - f"{SERVICE_API_URL_V2}/services/all/acuse/cove/", - data=json.dumps(payload), - headers={"Content-Type": "application/json"} - ) - # Aquí puedes continuar con el resto de tu lógica - - print(f"Servicio enviado para pedimento {pedimento.pedimento}") + try: + response = requests.post( + f"{SERVICE_API_URL_V2}/services/all/acuse/cove/", + data=json.dumps(payload), + headers={"Content-Type": "application/json"}, + timeout=60 + ) + response.raise_for_status() + logging.info(f"Acuses de COVEs encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}") + except requests.exceptions.RequestException as e: + logging.error(f"Error encolando acuses de COVEs para pedimento {pedimento.pedimento}: {e}") + continue @shared_task def procesar_acuses(organizacion_id): @@ -401,14 +465,18 @@ def procesar_acuses(organizacion_id): "credencial": credenciales_dict } - response = requests.post( - f"{SERVICE_API_URL_V2}/services/all/acuse/pedimento/", - data=json.dumps(payload), - headers={"Content-Type": "application/json"} - ) - # Aquí puedes continuar con el resto de tu lógica - - print(f"Servicio enviado para pedimento {pedimento.pedimento}") + try: + response = requests.post( + f"{SERVICE_API_URL_V2}/services/all/acuse/pedimento/", + data=json.dumps(payload), + headers={"Content-Type": "application/json"}, + timeout=60 + ) + response.raise_for_status() + logging.info(f"Acuses encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}") + except requests.exceptions.RequestException as e: + logging.error(f"Error encolando acuses para pedimento {pedimento.pedimento}: {e}") + continue @shared_task def procesar_edocs(organizacion_id): @@ -432,14 +500,18 @@ def procesar_edocs(organizacion_id): "credencial": credenciales_dict } - response = requests.post( - f"{SERVICE_API_URL_V2}/services/download/all/edocs/", - data=json.dumps(payload), - headers={"Content-Type": "application/json"} - ) - # Aquí puedes continuar con el resto de tu lógica - - print(f"Servicio enviado para pedimento {pedimento.pedimento}") + try: + response = requests.post( + f"{SERVICE_API_URL_V2}/services/download/all/edocs/", + data=json.dumps(payload), + headers={"Content-Type": "application/json"}, + timeout=60 + ) + response.raise_for_status() + logging.info(f"E-documents encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}") + except requests.exceptions.RequestException as e: + logging.error(f"Error encolando E-documents para pedimento {pedimento.pedimento}: {e}") + continue @shared_task def procesar_partidas(organizacion_id): @@ -447,29 +519,42 @@ def procesar_partidas(organizacion_id): organizacion_id=organizacion_id, partidas__isnull=False ).distinct() - + for pedimento in pedimentos: - if pedimento.partidas.filter(descargado=False).exists(): # Tipo 4: Partidas - # Convertir el pedimento a JSON usando el serializer - pedimento_dict = pedimento_to_dict(pedimento) - credenciales = Vucem.objects.filter(id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id).first() + partidas_pendientes = list(pedimento.partidas.filter(descargado=False)) + if not partidas_pendientes: + continue - credenciales_dict = credenciales_to_dict(credenciales) - - payload = { - "partidas": [partida_to_dict(partida) for partida in pedimento.partidas.filter(descargado=False)], - "pedimento": pedimento_dict, - "credencial": credenciales_dict - } + pedimento_dict = pedimento_to_dict(pedimento) + credenciales = Vucem.objects.filter( + id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id + ).first() + credenciales_dict = credenciales_to_dict(credenciales) + payload = { + "partidas": [partida_to_dict(p) for p in partidas_pendientes], + "pedimento": pedimento_dict, + "credencial": credenciales_dict + } + + try: response = requests.post( - f"{SERVICE_API_URL_V2}/services/all/partidas/", + f"{SERVICE_API_URL_V2}/services/all/partidas/", data=json.dumps(payload), - headers={"Content-Type": "application/json"} + headers={"Content-Type": "application/json"}, + timeout=60 ) - # Aquí puedes continuar con el resto de tu lógica - - print(f"Servicio enviado para pedimento {pedimento.pedimento}") + response.raise_for_status() + result = response.json() + logging.info( + f"Partidas encoladas para pedimento {pedimento.pedimento}: " + f"{result.get('total', 0)} de {len(partidas_pendientes)}" + ) + except requests.exceptions.RequestException as e: + logging.error( + f"Error encolando partidas para pedimento {pedimento.pedimento}: {e}" + ) + continue @shared_task def documentos_con_errores(organizacion_id): diff --git a/api/customs/urls.py b/api/customs/urls.py index f706dac..1859957 100644 --- a/api/customs/urls.py +++ b/api/customs/urls.py @@ -62,6 +62,7 @@ from .views_auditor import ( auditor_obtener_peticion_edocument_vu, auditor_obtener_respuesta_edocument_vu, auditar_pedimento_endpoint, + procesar_pedimento_completo_endpoint, ) urlpatterns = [ @@ -80,6 +81,7 @@ urlpatterns = [ path('auditor/auditar-acuse/pedimento/', auditar_acuse_pedimento_endpoint, name='auditar-acuse-pedimento'), path('auditor/auditar-remesa/pedimento/', auditar_procesamiento_remesa_pedimento_endpoint, name='auditar-remesa-pedimento'), path('auditor/auditar-pedimento/', auditar_pedimento_endpoint, name='auditar-pedimento'), + path('auditor/procesar-pedimento-completo/pedimento/', procesar_pedimento_completo_endpoint, name='procesar-pedimento-completo-pedimento'), path('auditor/procesar-pedimentos/organizaciones/', auditor_procesar_pedimentos_organizacion, name='procesar-pedimentos-organizaciones'), path('auditor/peticion-respuesta/pedimento-vu/', auditar_peticion_respuesta_pedimento_completo, name='peticion-respuesta-pedimento-vu'), diff --git a/api/customs/views.py b/api/customs/views.py index 82a52dd..71cc0f8 100644 --- a/api/customs/views.py +++ b/api/customs/views.py @@ -2505,6 +2505,7 @@ class ViewSetEDocument(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada 'partial_update': 'edocuments.edit', 'destroy': 'edocuments.delete', 'bulk_delete_edocs_vu': 'edocuments.delete', + 'reset_acuse': 'edocuments.edit', } codename = perms.get(self.action, 'edocuments.view') return [IsAuthenticated(), require_permission(codename)()] @@ -2531,6 +2532,88 @@ class ViewSetEDocument(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada def perform_destroy(self, instance): instance.delete() + @action(detail=True, methods=['post'], url_path='reset-acuse') + def reset_acuse(self, request, pk=None): + """ + Detecta inconsistencia cuando acuse_descargado=True pero no existe el documento + de acuse (tipo 4). Crea un registro de error tipo 26 para Errores VU y + restablece acuse_descargado=False para permitir reintentar. + """ + from api.record.models import Document, DocumentType + import logging + logger = logging.getLogger('api.customs.views') + + edoc = self.get_object() + + if not edoc.acuse_descargado: + return Response( + {"error": "El acuse no está marcado como descargado"}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Verificar si el acuse PDF (tipo 4 = Pedimento Acuse) existe realmente + acuse_disponible = Document.objects.filter( + pedimento=edoc.pedimento, + archivo__icontains=edoc.numero_edocument, + document_type_id=4 + ).exists() + + if acuse_disponible: + return Response( + {"status": "El acuse está disponible correctamente", "acuse_disponible": True}, + status=status.HTTP_200_OK + ) + + # Inconsistencia confirmada: crear documento de error tipo 26 para Errores VU + doc_type_error = DocumentType.objects.filter(id=26).first() + if doc_type_error: + error_content = ( + f"Inconsistencia detectada: el acuse del EDocument {edoc.numero_edocument} " + f"fue marcado como descargado pero el documento no se encuentra disponible. " + f"El estado fue restablecido para permitir reprocesamiento." + ).encode('utf-8') + + try: + with tempfile.NamedTemporaryFile( + mode='wb', suffix='.txt', delete=False + ) as f: + f.write(error_content) + tmp_path = f.name + + pedimento_app = getattr(edoc.pedimento, 'pedimento_app', str(edoc.pedimento.pedimento)) + file_name = f"error_acuse_{edoc.numero_edocument}.txt" + + saved_path = storage_service.save_document_from_path( + file_path=tmp_path, + file_name=file_name, + organizacion_id=edoc.organizacion_id, + pedimento_app=pedimento_app + ) + + if saved_path: + Document.objects.create( + organizacion=edoc.organizacion, + pedimento=edoc.pedimento, + archivo=saved_path, + document_type=doc_type_error, + extension='TXT', + size=len(error_content), + fuente=None, + ) + except Exception as e: + logger.error( + f"Error creando documento de error para acuse {edoc.numero_edocument}: {e}" + ) + finally: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + + edoc.acuse_descargado = False + edoc.save() + + serializer = self.get_serializer(edoc) + return Response(serializer.data, status=status.HTTP_200_OK) + class ViewSetCove(viewsets.ModelViewSet, OrganizacionFiltradaMixin): """ ViewSet for Cove model. diff --git a/api/customs/views_auditor.py b/api/customs/views_auditor.py index 193bb3a..21be114 100644 --- a/api/customs/views_auditor.py +++ b/api/customs/views_auditor.py @@ -15,7 +15,7 @@ from .tasks.auditoria import ( auditar_remesas, ) from .tasks.internal_services import auditar_pedimentos -from .tasks.microservice_v2 import procesar_pedimentos_completos +from .tasks.microservice_v2 import procesar_pedimentos_completos, procesar_pedimento_completo_individual from api.customs.models import Pedimento from api.organization.models import Organizacion from api.record.models import Document @@ -25,6 +25,9 @@ import os from api.utils.storage_service import storage_service import logging import uuid + +_ERROR_DOCUMENT_TYPES = [10, 14, 16, 18, 20, 22, 24, 26] + logger = logging.getLogger('api.customs.views_auditor') def get_document_content(documento): @@ -1680,98 +1683,155 @@ def auditor_obtener_respuesta_edocument_vu(request): @permission_classes([IsAuthenticated, require_permission('auditoria.process')]) def auditar_pedimento_endpoint(request): """ - Audita un pedimento específico verificando si existe su XML y extrayendo información. + Audita el pedimento completo (PC): ¿está descargado? ¿se puede procesar? + Incluye diagnóstico de campos y errores detectados por tipo de documento. """ 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: - # Validar permisos y existencia del pedimento - pedimento = Pedimento.objects.get(id=pedimento_id) + pedimento = Pedimento.objects.select_related( + 'organizacion', 'contribuyente' + ).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 ) - - # Buscar documentos XML del pedimento - documentos_xml = Document.objects.filter( - pedimento=pedimento, - archivo__endswith='.xml', + + # PC descargado (type 2) + pc_descargado = pedimento.documents.filter( + document_type_id=2, organizacion=pedimento.organizacion + ).exists() + + # Fuente de carga + fuente = 'datastage' if pedimento.consultar_vucem else 'manual' + + # Diagnóstico de campos + aduana = pedimento.aduana or '' + patente = pedimento.patente or '' + numero_pedimento = pedimento.pedimento or '' + + aduana_valida = bool(aduana) and aduana.isdigit() and 2 <= len(aduana) <= 3 + patente_valida = bool(patente) and patente.isdigit() and len(patente) == 4 + pedimento_valido = bool(numero_pedimento) and numero_pedimento.isdigit() and len(numero_pedimento) >= 7 + numero_operacion_presente = bool(pedimento.numero_operacion) + + from api.vucem.models import CredencialesImportador + tiene_contribuyente = pedimento.contribuyente is not None + tiene_credenciales = False + credenciales_detalle = None + if tiene_contribuyente: + credencial = CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first() + tiene_credenciales = bool(credencial and credencial.vucem) + if credencial and not credencial.vucem: + credenciales_detalle = 'Credencial encontrada pero sin cuenta VUCEM asociada' + elif not credencial: + credenciales_detalle = f'Sin credenciales VUCEM para RFC {pedimento.contribuyente.rfc}' + + razones = [] + if not aduana_valida: + razones.append(f'Aduana inválida o ausente (valor: "{aduana}")') + if not patente_valida: + razones.append(f'Patente inválida o ausente (valor: "{patente}")') + if not pedimento_valido: + razones.append(f'Número de pedimento inválido (valor: "{numero_pedimento}")') + if not tiene_contribuyente: + razones.append('Sin contribuyente asignado') + elif not tiene_credenciales: + razones.append(credenciales_detalle or 'Sin credenciales VUCEM') + + puede_procesar = len(razones) == 0 + + datos = { + 'aduana': aduana or None, + 'patente': patente or None, + 'numero_pedimento': numero_pedimento or None, + 'numero_operacion': pedimento.numero_operacion, + 'contribuyente_rfc': pedimento.contribuyente.rfc if pedimento.contribuyente else None, + 'contribuyente_nombre': str(pedimento.contribuyente) if pedimento.contribuyente else None, + } + + validacion = { + 'aduana_valida': aduana_valida, + 'patente_valida': patente_valida, + 'pedimento_valido': pedimento_valido, + 'numero_operacion_presente': numero_operacion_presente, + 'tiene_contribuyente': tiene_contribuyente, + 'tiene_credenciales_vucem': tiene_credenciales, + } + + # Errores por tipo de documento + docs_error = ( + Document.objects + .filter( + pedimento=pedimento, + organizacion=pedimento.organizacion, + document_type_id__in=_ERROR_DOCUMENT_TYPES, + ) + .select_related('document_type') + .order_by('document_type_id') ) - - if not documentos_xml.exists(): - return Response({ - 'pedimento_id': str(pedimento_id), - 'pedimento': pedimento.pedimento, - 'pedimento_app': pedimento.pedimento_app, - 'archivos_xml_encontrados': 0, - 'mensaje': 'No se encontraron archivos XML para este pedimento', - 'auditoria_completa': False - }, status=status.HTTP_200_OK) - - # Lista para almacenar información de cada XML - xmls_analizados = [] - informacion_extraida = [] - - for documento in documentos_xml: + errores_detectados = [ + { + 'documento_id': str(doc.id), + 'nombre_archivo': os.path.basename(str(doc.archivo)), + 'tipo_error': doc.document_type.descripcion if doc.document_type else 'Error desconocido', + 'tipo_id': doc.document_type_id, + } + for doc in docs_error + ] - print(f"documento >>>> {documento}") - logger.info(f"documento >>>> {documento}") + # XML del PC si existe + informacion_xml = None + doc_pc = pedimento.documents.filter( + document_type_id=2, + organizacion=pedimento.organizacion, + archivo__endswith='.xml', + ).first() + if doc_pc: + xml_content = get_document_content(doc_pc) + if xml_content: + info_pedimento = extraer_info_pedimento_xml(xml_content) + if info_pedimento: + informacion_xml = info_pedimento + actualizar_info_pedimento(pedimento, info_pedimento) - try: - xml_info = { - 'documento_id': str(documento.id), - 'nombre_archivo': os.path.basename(str(documento.archivo)), - 'tamanio': documento.size, - 'extension': documento.extension, - 'tipo_documento': documento.document_type.descripcion if documento.document_type else 'Desconocido' - } - - xml_content = get_document_content(documento) - - if xml_content is None: - xml_info['error_lectura'] = 'No se pudo descargar el archivo' - else: - info_pedimento = extraer_info_pedimento_xml(xml_content) - - if info_pedimento: - xml_info['informacion_extraida'] = info_pedimento - informacion_extraida.append(info_pedimento) + hay_pendientes = not pc_descargado + hay_errores = bool(errores_detectados) - # Actualizar el pedimento con la información encontrada si es necesario - actualizar_info_pedimento(pedimento, info_pedimento) - - xmls_analizados.append(xml_info) - - except Exception as e: - xmls_analizados.append({ - 'documento_id': str(documento.id), - 'nombre_archivo': os.path.basename(str(documento.archivo)), - 'error': f'Error procesando archivo: {str(e)}' - }) - - response_data = { + if hay_errores: + estado = 'CON_ERRORES' + elif hay_pendientes: + estado = 'PENDIENTE' + else: + estado = 'COMPLETO' + + return Response({ 'pedimento_id': str(pedimento_id), 'pedimento': pedimento.pedimento, 'pedimento_app': pedimento.pedimento_app, - 'archivos_xml_encontrados': len(xmls_analizados), - 'xmls_analizados': xmls_analizados, - 'informacion_extraida': informacion_extraida, - 'auditoria_completa': True, - 'mensaje': f'Auditoría completada para el pedimento {pedimento.pedimento}' - } - - return Response(response_data, status=status.HTTP_200_OK) - + 'estado': estado, + 'hay_pendientes': hay_pendientes, + 'hay_errores': hay_errores, + 'pc_descargado': pc_descargado, + 'puede_procesar': puede_procesar, + 'razones_no_puede_procesar': razones, + 'fuente': fuente, + 'datos': datos, + 'validacion': validacion, + 'errores_detectados': errores_detectados, + 'informacion_xml': informacion_xml, + }, status=status.HTTP_200_OK) + except Pedimento.DoesNotExist: return Response( {'error': 'Pedimento no encontrado'}, @@ -1783,6 +1843,164 @@ def auditar_pedimento_endpoint(request): status=status.HTTP_500_INTERNAL_SERVER_ERROR ) +@swagger_auto_schema( + method='post', + operation_description="Procesa el pedimento completo (tipo 2) de un pedimento específico llamando al microservicio VUCEM.", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'pedimento_id': openapi.Schema(type=openapi.TYPE_STRING, description='ID del pedimento'), + }, + required=['pedimento_id'] + ), + responses={ + 202: openapi.Response('Procesamiento encolado — usar task_id para consultar resultado'), + 200: openapi.Response('El pedimento ya tiene su documento completo descargado'), + 400: openapi.Response('Error en los parámetros o prerequisitos faltantes'), + 403: openapi.Response('No tiene permisos suficientes'), + 404: openapi.Response('Pedimento no encontrado'), + } +) +@api_view(['POST']) +@permission_classes([IsAuthenticated, require_permission('auditoria.process')]) +def procesar_pedimento_completo_endpoint(request): + """ + Diagnostica el pedimento completo y, si todo está en orden y aún no se ha + descargado, encola la tarea de procesamiento. + + Siempre devuelve diagnóstico completo: validación de campos, fuente, estado + del PC y razones por las que no se puede procesar si aplica. + """ + 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.select_related( + 'organizacion', 'contribuyente', 'tipo_operacion' + ).get(id=pedimento_id) + except Pedimento.DoesNotExist: + return Response({'error': 'Pedimento no encontrado'}, status=status.HTTP_404_NOT_FOUND) + + 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) + + # --- Diagnóstico de campos --- + aduana = pedimento.aduana or '' + patente = pedimento.patente or '' + numero_pedimento = pedimento.pedimento or '' + + aduana_valida = bool(aduana) and aduana.isdigit() and 2 <= len(aduana) <= 3 + patente_valida = bool(patente) and patente.isdigit() and len(patente) == 4 + pedimento_valido = bool(numero_pedimento) and numero_pedimento.isdigit() and len(numero_pedimento) >= 7 + numero_operacion_presente = bool(pedimento.numero_operacion) + + # --- Fuente de carga --- + # consultar_vucem=True indica que fue originado desde datastage + fuente = 'datastage' if pedimento.consultar_vucem else 'manual' + + # --- Estado del PC --- + pc_descargado = pedimento.documents.filter( + document_type_id=2, + organizacion=pedimento.organizacion + ).exists() + + # --- Credenciales VUCEM --- + from api.vucem.models import CredencialesImportador + tiene_contribuyente = pedimento.contribuyente is not None + tiene_credenciales = False + credenciales_detalle = None + if tiene_contribuyente: + credencial = CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first() + tiene_credenciales = bool(credencial and credencial.vucem) + if credencial and not credencial.vucem: + credenciales_detalle = 'Credencial encontrada pero sin cuenta VUCEM asociada' + elif not credencial: + credenciales_detalle = f'Sin credenciales VUCEM para RFC {pedimento.contribuyente.rfc}' + + # --- Puede procesar --- + razones = [] + if not aduana_valida: + razones.append(f'Aduana inválida o ausente (valor: "{aduana}")') + if not patente_valida: + razones.append(f'Patente inválida o ausente (valor: "{patente}")') + if not pedimento_valido: + razones.append(f'Número de pedimento inválido (valor: "{numero_pedimento}")') + if not tiene_contribuyente: + razones.append('Sin contribuyente asignado') + elif not tiene_credenciales: + razones.append(credenciales_detalle or 'Sin credenciales VUCEM') + + puede_procesar = len(razones) == 0 + + datos = { + 'aduana': aduana or None, + 'patente': patente or None, + 'numero_pedimento': numero_pedimento or None, + 'numero_operacion': pedimento.numero_operacion, + 'regimen': pedimento.regimen, + 'clave_pedimento': pedimento.clave_pedimento, + 'fecha_pago': str(pedimento.fecha_pago) if pedimento.fecha_pago else None, + 'contribuyente_rfc': pedimento.contribuyente.rfc if pedimento.contribuyente else None, + 'contribuyente_nombre': str(pedimento.contribuyente) if pedimento.contribuyente else None, + 'remesas': pedimento.remesas, + 'numero_partidas': pedimento.numero_partidas, + } + + validacion = { + 'aduana_valida': aduana_valida, + 'patente_valida': patente_valida, + 'pedimento_valido': pedimento_valido, + 'numero_operacion_presente': numero_operacion_presente, + 'tiene_contribuyente': tiene_contribuyente, + 'tiene_credenciales_vucem': tiene_credenciales, + 'puede_procesar': puede_procesar, + 'razones_no_puede_procesar': razones, + } + + base_response = { + 'pedimento_id': str(pedimento_id), + 'pedimento': pedimento.pedimento, + 'pedimento_app': pedimento.pedimento_app, + 'fuente': fuente, + 'datos': datos, + 'validacion': validacion, + 'pc_descargado': pc_descargado, + } + + # Ya descargado — devolver diagnóstico sin encolar + if pc_descargado: + return Response({ + **base_response, + 'estado': 'ya_descargado', + 'mensaje': 'El pedimento completo ya fue descargado', + }, status=status.HTTP_200_OK) + + # No puede procesar — devolver diagnóstico con razones + if not puede_procesar: + return Response({ + **base_response, + 'estado': 'no_puede_procesar', + 'mensaje': 'El pedimento no cumple los requisitos para procesar', + }, status=status.HTTP_200_OK) + + # Todo en orden — encolar + task = procesar_pedimento_completo_individual.delay(str(pedimento_id)) + logger.info(f"Procesamiento PC encolado: {pedimento.pedimento} (task={task.id})") + + return Response({ + **base_response, + 'estado': 'encolado', + 'task_id': task.id, + 'mensaje': f'Procesamiento encolado para {pedimento.pedimento_app}', + }, status=status.HTTP_202_ACCEPTED) + + def actualizar_info_pedimento(pedimento, info_xml): """ Actualiza la información del pedimento con los datos extraídos del XML. -- 2.49.1