From fa0d49a6d53af5778914167dfd12aa8706b34960 Mon Sep 17 00:00:00 2001 From: Jair Cedillo Date: Tue, 14 Oct 2025 14:05:19 -0500 Subject: [PATCH] feat: Mejorar endpoints de carga masiva de documentos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✨ Nuevas funcionalidades: - Corregir nomenclatura en bulk-create de pedimentos usando nombres exactos de archivos - Endpoint bulk-upload para cargar múltiples documentos a un pedimento existente - Soporte completo para archivos RAR y ZIP con manejo robusto 🔧 Mejoras técnicas bulk-create: - Subdirectorios usan nombre exacto del archivo sin extensión (ej: 24-01-3420-1234567/) - Resolución del problema de validación de nomenclatura inválida - Mensajes de error mejorados con archivo original específico - Procesamiento optimizado de múltiples archivos ZIP/RAR simultáneos 🔧 Mejoras técnicas bulk-upload: - Organización heredada del pedimento en lugar del usuario - Validación de cuotas de almacenamiento por organización - Manejo de errores por archivo individual - Soporte para múltiples tipos de archivo 📦 Dependencias: - Agregado rarfile==4.1 para soporte completo de archivos RAR 🚀 Endpoints listos para producción: - POST /api/customs/pedimentos/bulk-create/ (crear pedimentos + documentos) - POST /api/record/documents/bulk-upload/ (subir documentos a pedimento existente) --- api/customs/views.py | 373 +++++++++++++++++++++++++++++++++++++++++++ api/record/views.py | 205 ++++++++++++++++++++++++ requirements.txt | 1 + 3 files changed, 579 insertions(+) diff --git a/api/customs/views.py b/api/customs/views.py index 7fb8ed2..348fb5b 100644 --- a/api/customs/views.py +++ b/api/customs/views.py @@ -38,6 +38,23 @@ 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 +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 from .tasks.microservice_v2 import * @@ -324,6 +341,362 @@ 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 + 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): diff --git a/api/record/views.py b/api/record/views.py index a3a775d..9e7d7a7 100644 --- a/api/record/views.py +++ b/api/record/views.py @@ -312,6 +312,211 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin): response_status = status.HTTP_200_OK return Response(response_data, status=response_status) + + @action(detail=False, methods=['post'], url_path='bulk-upload', parser_classes=[MultiPartParser]) + def bulk_upload(self, request): + """ + Endpoint para subir múltiples documentos a un pedimento específico. + + FormData esperado: + - pedimento_id: UUID del pedimento (requerido) + - files: Lista de archivos a subir (requerido) + + Nota: Se usa automáticamente el tipo de documento "Documento General" + + Respuesta exitosa: + { + "message": "Documentos subidos exitosamente", + "uploaded_count": 5, + "uploaded_documents": [ + { + "id": "uuid1", + "filename": "documento1.pdf", + "size": 1024000, + "extension": "pdf" + }, + ... + ], + "space_used_mb": 25.6, + "failed_files": [], + "errors": [] + } + + Respuesta con errores: + { + "message": "Algunos documentos no pudieron ser subidos", + "uploaded_count": 3, + "uploaded_documents": [...], + "space_used_mb": 15.2, + "failed_files": ["archivo4.pdf", "archivo5.doc"], + "errors": ["Archivo demasiado grande: archivo4.pdf", "Tipo de archivo no soportado: archivo5.doc"] + } + """ + + # Validar datos requeridos + pedimento_id = request.data.get('pedimento_id') + if not pedimento_id: + return Response( + {"error": "Se requiere el campo 'pedimento_id'"}, + status=status.HTTP_400_BAD_REQUEST + ) + + files = request.FILES.getlist('files') + if not files: + return Response( + {"error": "Se requiere al menos un archivo para subir"}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Validar usuario autenticado + if not request.user.is_authenticated: + return Response( + {"error": "Usuario no autenticado"}, + status=status.HTTP_401_UNAUTHORIZED + ) + + # Obtener el pedimento primero para usar su organización + from api.customs.models import Pedimento + try: + pedimento = Pedimento.objects.get(id=pedimento_id) + except Pedimento.DoesNotExist: + return Response( + {"error": "Pedimento no encontrado"}, + status=status.HTTP_404_NOT_FOUND + ) + + # Usar la organización del pedimento + organizacion = pedimento.organizacion + + # Validar que el usuario tenga permisos para esta organización + if not request.user.is_superuser: + if not hasattr(request.user, 'organizacion') or request.user.organizacion != organizacion: + return Response( + {"error": "No tienes permisos para subir documentos a este pedimento"}, + status=status.HTTP_403_FORBIDDEN + ) + + # Usar tipo de documento por defecto siempre + document_type, created = DocumentType.objects.get_or_create( + nombre="Documento General", + defaults={'descripcion': "Documento general sin tipo específico"} + ) + if created: + print(f"✅ DocumentType creado: {document_type.nombre} (ID: {document_type.id})") + else: + print(f"♻️ DocumentType existente: {document_type.nombre} (ID: {document_type.id})") + + uploaded_documents = [] + failed_files = [] + errors = [] + total_space_used = 0 + + try: + with transaction.atomic(): + # Obtener uso de almacenamiento + uso = UsoAlmacenamiento.objects.select_for_update().get_or_create( + organizacion=organizacion, + defaults={'espacio_utilizado': 0} + )[0] + + # Calcular límites + max_almacenamiento_bytes = organizacion.licencia.almacenamiento * 1024 ** 3 + espacio_inicial = uso.espacio_utilizado + + # Calcular el tamaño total de todos los archivos + total_files_size = sum(file.size for file in files) + nuevo_espacio_total = espacio_inicial + total_files_size + + # Validar que hay espacio suficiente para todos los archivos + if nuevo_espacio_total > max_almacenamiento_bytes: + espacio_faltante = nuevo_espacio_total - max_almacenamiento_bytes + return Response({ + "error": "Espacio de almacenamiento insuficiente para todos los archivos", + "detalle": { + "espacio_faltante_gb": round(espacio_faltante / (1024 ** 3), 2), + "espacio_utilizado_gb": round(espacio_inicial / (1024 ** 3), 2), + "limite_gb": organizacion.licencia.almacenamiento, + "archivos_gb": round(total_files_size / (1024 ** 3), 4), + "total_archivos": len(files) + }, + "codigo": "bulk_storage_limit_exceeded" + }, status=status.HTTP_400_BAD_REQUEST) + + # Procesar cada archivo + espacio_usado_temp = espacio_inicial + + for file in files: + try: + # Validaciones por archivo + if not file.name: + failed_files.append("archivo_sin_nombre") + errors.append("Archivo sin nombre detectado") + continue + + # Obtener extensión del archivo + extension = file.name.split('.')[-1].lower() if '.' in file.name else '' + + # Crear el documento + document = Document.objects.create( + organizacion=organizacion, + pedimento_id=pedimento_id, + document_type=document_type, + archivo=file, + size=file.size, + extension=extension + ) + + # Actualizar espacio usado + espacio_usado_temp += file.size + total_space_used += file.size + + uploaded_documents.append({ + "id": str(document.id), + "filename": file.name, + "size": file.size, + "extension": extension, + "document_type": document_type.nombre + }) + + except Exception as e: + failed_files.append(file.name) + errors.append(f"Error al procesar {file.name}: {str(e)}") + continue + + # Actualizar el uso de almacenamiento final + uso.espacio_utilizado = espacio_usado_temp + uso.save() + + except Exception as e: + return Response( + {"error": f"Error durante el procesamiento masivo: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + # Convertir bytes a MB para la respuesta + space_used_mb = round(total_space_used / (1024 * 1024), 2) + + # Preparar respuesta + response_data = { + "uploaded_count": len(uploaded_documents), + "uploaded_documents": uploaded_documents, + "space_used_mb": space_used_mb, + "pedimento_id": str(pedimento_id), + "document_type": document_type.nombre + } + + if failed_files: + response_data.update({ + "message": "Algunos documentos no pudieron ser subidos", + "failed_files": failed_files, + "errors": errors + }) + response_status = status.HTTP_207_MULTI_STATUS + else: + response_data["message"] = "Documentos subidos exitosamente" + response_status = status.HTTP_201_CREATED + + return Response(response_data, status=response_status) class ProtectedDocumentDownloadView(APIView, DocumentosFiltradosMixin): permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] diff --git a/requirements.txt b/requirements.txt index 361dc10..64c7a1f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -50,6 +50,7 @@ python-dotenv==1.1.0 python-multipart==0.0.12 pytz==2025.2 PyYAML==6.0.2 +rarfile==4.1 redis==6.2.0 referencing==0.36.2 requests==2.32.4 -- 2.49.1