From 77f9fe438955714521daaedbf9e5639993e3545f Mon Sep 17 00:00:00 2001 From: Luis Date: Mon, 24 Nov 2025 08:51:42 -0700 Subject: [PATCH] Se habilita funcionalidad para crear pediementos con sus documentos apartir de una carpeta, zip o rar --- api/customs/views.py | 437 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 436 insertions(+), 1 deletion(-) diff --git a/api/customs/views.py b/api/customs/views.py index debbf5a..168d1e3 100644 --- a/api/customs/views.py +++ b/api/customs/views.py @@ -38,7 +38,68 @@ from api.customs.serializers import ( from api.logger.mixins import LoggingMixin from mixins.filtrado_organizacion import OrganizacionFiltradaMixin, ProcesosPorOrganizacionMixin import requests +import os +import re +import zipfile +import tempfile +import shutil +import subprocess +from datetime import datetime +from django.core.files.base import ContentFile +from django.db import transaction +from rest_framework.parsers import MultiPartParser, FormParser +from api.record.models import Document, DocumentType +# Importar rarfile de manera opcional +try: + import rarfile + RAR_SUPPORT = True +except ImportError: + RAR_SUPPORT = False + + +def extract_rar_to_dir(rar_path, dest_dir): + """ + Extrae un archivo RAR a `dest_dir` usando varios mecanismos de respaldo: + 1) intentará usar la librería `rarfile` si está disponible, + 2) intentará ejecutar la utilidad `unrar` si está instalada en el sistema, + 3) intentará ejecutar `7z`/`7za` (p7zip) si está instalada. + + Lanza Exception con mensaje explicativo si falla. + """ + # Intento con rarfile (Python) + if RAR_SUPPORT: + try: + # rarfile puede trabajar con rutas en disco mejor que con file-like + with rarfile.RarFile(rar_path) as rf: + rf.extractall(dest_dir) + return + except Exception as e: + # Si rarfile falla (por ejemplo RarCannotExec), seguimos con herramientas externas + # Hacer log para depuración + print(f"rarfile extraction failed, will try external tools: {e}") + + # Intento con comandos externos + # Probar 'unrar' primero + external_cmds = [ + ['unrar', 'x', '-o+', rar_path, dest_dir], + ['7z', 'x', rar_path, f'-o{dest_dir}', '-y'], + ['7za', 'x', rar_path, f'-o{dest_dir}', '-y'] + ] + + for cmd in external_cmds: + try: + subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + return + except FileNotFoundError: + # El ejecutable no existe en PATH, intentar siguiente + continue + except subprocess.CalledProcessError as e: + # El comando falló en la extracción; intentar siguiente + print(f"External extractor failed ({cmd[0]}): {e}") + continue + + raise Exception("No se encontró una herramienta válida para extraer RAR (rarfile sin backend, 'unrar' o '7z' no disponibles o extracción fallida). Instale 'unrar' o 'p7zip' y asegúrese de que estén en PATH, o configure rarfile con un backend.") from .tasks.microservice_v2 import * from .tasks.auditoria import crear_partidas_por_pedimento @@ -151,7 +212,7 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada filterset_fields = ['patente', 'aduana', 'tipo_operacion', 'clave_pedimento', 'pedimento', 'existe_expediente', 'contribuyente', 'curp_apoderado', 'fecha_pago', 'pedimento_app'] search_fields = ['pedimento', 'pedimento_app', 'agente_aduanal', 'clave_pedimento'] - + def get_queryset(self): return self.get_queryset_filtrado_por_organizacion() # Tambien filtra por importador @@ -324,6 +385,380 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada return Response(response_data, status=response_status) + @action(detail=False, methods=['post'], url_path='bulk-create', parser_classes=[MultiPartParser, FormParser]) + def bulk_create(self, request): + """ + Endpoint para crear múltiples pedimentos de manera masiva desde archivos. + + FormData esperado: + - contribuyente: string (nombre del contribuyente) + - archivos: files (pueden ser múltiples archivos: zip, rar o individuales) + + Nomenclatura esperada de archivos: anio-aduana-patente-pedimento + - anio: 2 dígitos (ej: 24) + - aduana: 2 o 3 dígitos (ej: 01, 123) + - patente: 4 dígitos (ej: 3420) + - pedimento: 7 dígitos (ej: 1234567) + + Ejemplo: 24-01-3420-1234567 + + Nota: Cada archivo ZIP/RAR se procesa independientemente en su propio subdirectorio. + + Respuesta exitosa: + { + "message": "Pedimentos creados exitosamente", + "created_count": 5, + "created_pedimentos": [...], + "documents_created": 15, + "processed_files": 3, + "summary": "Procesados 3 archivo(s): 5 pedimento(s) creado(s), 15 documento(s) asociado(s)", + "failed_files": [], + "errors": [] + } + """ + print(request.data) + # Validar datos requeridos + contribuyente = request.data.get('contribuyente') + archivos = request.FILES.getlist('archivos') + + if not contribuyente: + return Response( + {"error": "Se requiere el campo 'contribuyente'"}, + status=status.HTTP_400_BAD_REQUEST + ) + + if not archivos: + return Response( + {"error": "Se requiere al menos un archivo"}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Validar organización del usuario + if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'): + return Response( + {"error": "Usuario no autenticado o sin organización"}, + status=status.HTTP_400_BAD_REQUEST + ) + + organizacion = request.user.organizacion + + # Regex para validar nomenclatura: anio-aduana-patente-pedimento + nomenclatura_pattern = re.compile(r'^(\d{2})-(\d{2,3})-(\d{4})-(\d{7})$') + + created_pedimentos = [] + failed_files = [] + errors = [] + documents_created = 0 + temp_dir = None + + # Obtener DocumentType ANTES de la transacción atómica + print("Intentando obtener o crear DocumentType...") + try: + # Primero intentar obtener si ya existe + try: + document_type = DocumentType.objects.get(nombre="Pedimento") + print(f"DocumentType obtenido existente: {document_type.nombre} (ID: {document_type.id})") + except DocumentType.DoesNotExist: + # Si no existe, crear uno nuevo + document_type = DocumentType.objects.create( + nombre="Pedimento", + descripcion="Documento de pedimento" + ) + print(f"DocumentType creado nuevo: {document_type.nombre} (ID: {document_type.id})") + + except Exception as e: + print(f"Error al obtener/crear DocumentType: {str(e)}") + # Como fallback, intentar obtener cualquier DocumentType existente + try: + document_type = DocumentType.objects.first() + if document_type: + print(f"Usando DocumentType existente como fallback: {document_type.nombre} (ID: {document_type.id})") + else: + print("No hay DocumentType disponible") + return Response( + {"error": "No se pudo configurar el tipo de documento y no hay tipos existentes"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + except Exception as fallback_error: + print(f"Error en fallback: {str(fallback_error)}") + return Response( + {"error": f"Error crítico al configurar tipo de documento: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + try: + print("Iniciando transacción atómica...") + with transaction.atomic(): + # Crear directorio temporal + temp_dir = tempfile.mkdtemp() + print(f"Directorio temporal creado: {temp_dir}") + + # Procesar cada archivo enviado + for idx, archivo in enumerate(archivos): + archivo_name = archivo.name.lower() + print(f"Procesando archivo {idx + 1}/{len(archivos)}: {archivo_name}") + + # Crear subdirectorio para cada archivo usando el nombre del archivo sin extensión + archivo_name_sin_extension = os.path.splitext(archivo.name)[0] + sub_dir = os.path.join(temp_dir, archivo_name_sin_extension) + os.makedirs(sub_dir, exist_ok=True) + print(f"Subdirectorio creado: {sub_dir}") + + if archivo_name.endswith('.zip'): + # Manejar archivo ZIP + print("Es un archivo ZIP") + try: + with zipfile.ZipFile(archivo, 'r') as zip_ref: + zip_ref.extractall(sub_dir) + print("Archivo ZIP extraído exitosamente") + except zipfile.BadZipFile as e: + return Response( + {"error": f"Archivo ZIP corrupto o inválido: {archivo.name} - {str(e)}"}, + status=status.HTTP_400_BAD_REQUEST + ) + except Exception as e: + return Response( + {"error": f"Error al extraer ZIP {archivo.name}: {str(e)}"}, + status=status.HTTP_400_BAD_REQUEST + ) + elif archivo_name.endswith('.rar'): + # Manejar archivo RAR: guardar el archivo en disco y usar helper con fallbacks + # Guardar el archivo subido en un path temporal dentro del sub_dir + archivo_temp_path = os.path.join(sub_dir, archivo.name) + with open(archivo_temp_path, 'wb') as f: + for chunk in archivo.chunks(): + f.write(chunk) + try: + extract_rar_to_dir(archivo_temp_path, sub_dir) + print(f"Archivo RAR {archivo.name} extraído en {sub_dir}") + except Exception as e: + error_msg = str(e) + help_msg = "Instale 'unrar' o 'p7zip' (7z) y asegúrese de que estén en PATH, o instale y configure 'rarfile' con un backend." + return Response( + {"error": f"Error al extraer archivo RAR {archivo.name}: {error_msg}. {help_msg}"}, + status=status.HTTP_400_BAD_REQUEST + ) + + # if not RAR_SUPPORT: + # return Response( + # {"error": "Soporte para archivos RAR no disponible. Instalar rarfile: pip install rarfile"}, + # status=status.HTTP_400_BAD_REQUEST + # ) + # try: + # with rarfile.RarFile(archivo, 'r') as rar_ref: + # rar_ref.extractall(sub_dir) + # print(f"Archivo RAR {archivo.name} extraído en sub_dir") + # except rarfile.Error as e: + # return Response( + # {"error": f"Error al extraer archivo RAR {archivo.name}: {str(e)}"}, + # status=status.HTTP_400_BAD_REQUEST + # ) + + else: + # Asumir que es un archivo individual + # Crear el archivo en el subdirectorio + archivo_path = os.path.join(sub_dir, archivo.name) + with open(archivo_path, 'wb') as f: + for chunk in archivo.chunks(): + f.write(chunk) + print(f"Archivo individual {archivo.name} guardado en sub_dir:", archivo_path) + + # Recorrer todos los archivos extraídos o el directorio + print("Iniciando recorrido de archivos...") + for root, dirs, files in os.walk(temp_dir): + print(f"Revisando directorio: {root}") + print(f"Archivos encontrados: {files}") + + for file_name in files: + print(f"Procesando archivo: {file_name}") + file_path = os.path.join(root, file_name) + + # Obtener la ruta relativa para determinar la estructura de carpetas + relative_path = os.path.relpath(file_path, temp_dir) + print(f"Ruta relativa: {relative_path}") + + # Determinar si el archivo está en una carpeta que sigue la nomenclatura + folder_name = None + if os.path.dirname(relative_path): + # El archivo está dentro de una carpeta + folder_parts = relative_path.split(os.sep) + folder_name = folder_parts[0] # Primera carpeta (nombre del archivo ZIP/RAR sin extensión) + else: + # El archivo está en la raíz, usar el nombre del archivo sin extensión + folder_name = os.path.splitext(file_name)[0] + + print(f"Folder name para validación: {folder_name}") + + # Validar nomenclatura + match = nomenclatura_pattern.match(folder_name) + if not match: + print(f"Nomenclatura inválida: {folder_name}") + # Determinar el archivo original basado en el subdirectorio + archivo_original = folder_name + ('.zip' if any(f.endswith('.zip') for f in [a.name for a in archivos]) else '.rar') + failed_files.append({ + "file": relative_path, + "archivo_original": archivo_original, + "error": f"Nomenclatura inválida: {folder_name}. Esperado: anio-aduana-patente-pedimento" + }) + continue + + print(f"Nomenclatura válida: {folder_name}") + anio, aduana, patente, pedimento_num = match.groups() + print(f"Extraído - Año: {anio}, Aduana: {aduana}, Patente: {patente}, Pedimento: {pedimento_num}") + + # Crear fecha_pago basada en el año + try: + # Convertir año de 2 dígitos a 4 dígitos + anio_completo = 2000 + int(anio) if int(anio) < 50 else 1900 + int(anio) + fecha_pago = datetime(anio_completo, 1, 1).date() + print(f"Fecha de pago calculada: {fecha_pago}") + except ValueError: + archivo_original = folder_name + ('.zip' if any(f.endswith('.zip') for f in [a.name for a in archivos]) else '.rar') + failed_files.append({ + "file": relative_path, + "archivo_original": archivo_original, + "error": f"Año inválido: {anio}" + }) + continue + + # Generar pedimento_app + pedimento_app = f"{anio}-{aduana.zfill(2)}-{patente}-{pedimento_num}" + print(f"Pedimento_app generado: {pedimento_app}") + print(f"Buscando pedimento existente con pedimento_app: {pedimento_app} y organización ID: {organizacion.id}") + # Verificar si el pedimento ya existe + existing_pedimento = Pedimento.objects.filter( + pedimento_app=pedimento_app, + organizacion=organizacion + ).first() + + print(f"Pedimento existente: {existing_pedimento is not None}") + + if not existing_pedimento: + print("📝 Pedimento no existe, creando nuevo...") + # Crear nuevo pedimento + try: + print("🔄 Iniciando creación de pedimento...") + + # Obtener o crear el importador + print(f"🏢 Buscando/creando importador con RFC: {contribuyente}") + importador, created = Importador.objects.get_or_create( + rfc=contribuyente, + defaults={ + 'nombre': f"Importador {contribuyente}", + 'organizacion': organizacion + } + ) + if created: + print(f"✅ Importador creado: {importador.rfc} - {importador.nombre}") + else: + print(f"♻️ Importador existente: {importador.rfc} - {importador.nombre}") + + pedimento = Pedimento.objects.create( + organizacion=organizacion, + contribuyente=importador, + pedimento=int(pedimento_num), + aduana=int(aduana), + patente=int(patente), + fecha_pago=fecha_pago, + pedimento_app=pedimento_app, + agente_aduanal=f"Agente {patente}", # Valor por defecto + clave_pedimento="A1" # Valor por defecto + ) + + print(f"✅ Pedimento creado exitosamente: ID {pedimento.id}, pedimento_app: {pedimento_app}") + + created_pedimentos.append({ + "id": str(pedimento.id), + "pedimento_app": pedimento_app, + "contribuyente": importador.rfc, + "contribuyente_nombre": importador.nombre + }) + + except Exception as e: + print(f"❌ Error al crear pedimento: {str(e)}") + archivo_original = folder_name + ('.zip' if any(f.endswith('.zip') for f in [a.name for a in archivos]) else '.rar') + failed_files.append({ + "file": relative_path, + "archivo_original": archivo_original, + "error": f"Error al crear pedimento: {str(e)}" + }) + continue + else: + print(f"♻️ Usando pedimento existente: ID {existing_pedimento.id}") + # Usar pedimento existente + pedimento = existing_pedimento + + print(f"🔄 Iniciando creación de documento para pedimento ID: {pedimento.id}") + # Crear documento asociado al pedimento + try: + print("📖 Leyendo archivo desde directorio temporal...") + # Leer el archivo desde el directorio temporal + with open(file_path, 'rb') as f: + file_content = f.read() + + print(f"📄 Archivo leído: {len(file_content)} bytes") + # Crear ContentFile que Django puede manejar correctamente + django_file = ContentFile(file_content, name=file_name) + + print(f"Creando documento para archivo: {file_name}") + # Crear documento - Django automáticamente guardará el archivo en media/documents/ + document = Document.objects.create( + organizacion=organizacion, + pedimento_id=pedimento.id, + document_type=document_type, + archivo=django_file, + size=len(file_content), + extension=os.path.splitext(file_name)[1].lower().lstrip('.') + ) + print(f"Documento creado exitosamente: {document.id}") + + documents_created += 1 + print(f"📊 Total documentos creados hasta ahora: {documents_created}") + + except Exception as e: + print(f"❌ Error al crear documento: {str(e)}") + archivo_original = folder_name + ('.zip' if any(f.endswith('.zip') for f in [a.name for a in archivos]) else '.rar') + failed_files.append({ + "file": relative_path, + "archivo_original": archivo_original, + "error": f"Error al crear documento: {str(e)}" + }) + continue + + print(f"🏁 Procesamiento completado. Archivos procesados en este directorio.") + except Exception as e: + return Response( + {"error": f"Error durante el procesamiento: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + finally: + # Limpiar directorio temporal + if temp_dir and os.path.exists(temp_dir): + shutil.rmtree(temp_dir) + + # Preparar respuesta + response_data = { + "created_count": len(created_pedimentos), + "created_pedimentos": created_pedimentos, + "documents_created": documents_created, + "failed_files": failed_files, + "processed_files": len(archivos), + "summary": f"Procesados {len(archivos)} archivo(s): {len(created_pedimentos)} pedimento(s) creado(s), {documents_created} documento(s) asociado(s)" + } + + if failed_files: + response_data.update({ + "message": "Procesamiento completado con algunos errores", + "errors": [item["error"] for item in failed_files] + }) + response_status = status.HTTP_207_MULTI_STATUS + else: + response_data["message"] = "Pedimentos creados exitosamente" + response_status = status.HTTP_201_CREATED + + return Response(response_data, status=response_status) + + my_tags = ['Pedimentos'] class PartidaViewSet(viewsets.ModelViewSet): -- 2.49.1