diff --git a/api/customs/tasks/bulk_upload.py b/api/customs/tasks/bulk_upload.py index a2dd3b3..f9c5809 100644 --- a/api/customs/tasks/bulk_upload.py +++ b/api/customs/tasks/bulk_upload.py @@ -27,35 +27,35 @@ def normalize_filename(filename): return filename -def get_clean_base_filename(filename): - """ - Obtiene el nombre base limpio sin el sufijo de Django. - """ - normalized = normalize_filename(filename) - name_without_ext, ext = os.path.splitext(normalized) - - django_suffix = extract_django_suffix(name_without_ext) - if django_suffix: - base_name = name_without_ext[:-8] - else: - base_name = name_without_ext - - base_name = re.sub(r'(_copy|_copia|_-_copia|_-_copy)(_\d+)?$', '', base_name) - - return base_name.lower().strip('_') - - def extract_django_suffix(filename): """ - Extrae el sufijo único que Django añade a los archivos. + Extrae el sufijo UUID de 8 chars que storage_service añade a los archivos. """ name_without_ext = os.path.splitext(filename)[0] - match = re.search(r'_([a-zA-Z0-9]{7})$', name_without_ext) + match = re.search(r'_([a-zA-Z0-9]{8})$', name_without_ext) if match: return match.group(1) return None +def get_clean_base_filename(filename): + """ + Obtiene el nombre base limpio sin el sufijo UUID de storage_service. + """ + normalized = normalize_filename(filename) + name_without_ext, ext = os.path.splitext(normalized) + + django_suffix = extract_django_suffix(name_without_ext) + if django_suffix: + base_name = name_without_ext[:-9] # elimina _XXXXXXXX (underscore + 8 chars UUID) + else: + base_name = name_without_ext + + base_name = re.sub(r'(_copy|_copia|_-_copia|_-_copy)(_\d+)?$', '', base_name) + + return base_name.lower().strip('_') + + def is_same_document(existing_doc, new_filename): """ Compara si un documento existente y un nuevo archivo son el mismo documento. diff --git a/api/customs/views.py b/api/customs/views.py index 932432d..277201a 100644 --- a/api/customs/views.py +++ b/api/customs/views.py @@ -1,3 +1,4 @@ +from api.utils.storage_service import storage_service from config.settings import SERVICE_API_URL from django.shortcuts import render from rest_framework import viewsets @@ -61,7 +62,6 @@ except ImportError: # Importar tarea de procesamiento de pedimento (Celery) from api.customs.tasks.microservice import procesar_pedimento_completo_individual -from api.utils.storage_service import storage_service def get_available_extractors(): """ @@ -394,6 +394,131 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada except Exception as e: return Response({"error": f"Error inesperado al llamar al servicio API: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + @action(detail=True, methods=['post'], url_path='procesar-partidas') + def procesar_partidas(self, request, pk=None): + """ + Acción para disparar el procesamiento de un partidas de un pedimento existente. + Dispara la tarea `procesar_partidas_individual` de forma asíncrona + y devuelve el `task_id`. + """ + pedimento = self.get_object() + try: + from api.customs.tasks import microservice_v2 + + # Usar el nombre del servicio de Docker Compose en lugar de localhost + task = microservice_v2.procesar_partidas_pedimento.delay(pedimento.id) + # Verificar si la respuesta fue exitosa + if task.id: + return Response({"status": "Iniciando Procesamiento de Partidas", "task_id": task.id}, status=status.HTTP_202_ACCEPTED) + else: + return Response({"status": "El Servicio respondió con error", "task_id": 0}, status=status.HTTP_202_ACCEPTED) + except Exception as e: + return Response({"error": f"Error inesperado al llamar al servicio API: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + + @action(detail=True, methods=['post'], url_path='procesar-coves') + def procesar_coves(self, request, pk=None): + """ + Acción para disparar el procesamiento de un cove de un pedimento existente. + Dispara la tarea `procesar_coves_individual` de forma asíncrona + y devuelve el `task_id`. + """ + pedimento = self.get_object() + try: + from api.customs.tasks import microservice_v2 + + # Usar el nombre del servicio de Docker Compose en lugar de localhost + task = microservice_v2.procesar_coves_pedimento.delay(pedimento.id) + # Verificar si la respuesta fue exitosa + if task.id: + return Response({"status": "Iniciando Procesamiento de COVES", "task_id": task.id}, status=status.HTTP_202_ACCEPTED) + else: + return Response({"status": "Servicio API respondió con error", "task_id": 0}, status=status.HTTP_202_ACCEPTED) + except Exception as e: + return Response({"error": f"Error inesperado al llamar al servicio API: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + @action(detail=True, methods=['post'], url_path='procesar-acuse-coves') + def procesar_acuse_coves(self, request, pk=None): + """ + Acción para disparar el procesamiento de un acuse cove de un pedimento existente. + Dispara la tarea `procesar_acuse_coves_individual` de forma asíncrona + y devuelve el `task_id`. + """ + pedimento = self.get_object() + try: + # Usar el nombre del servicio de Docker Compose en lugar de localhost + from api.customs.tasks import microservice_v2 + + # Usar el nombre del servicio de Docker Compose en lugar de localhost + task = microservice_v2.procesar_acuse_coves_pedimento.delay(pedimento.id) + # Verificar si la respuesta fue exitosa + if task.id: + return Response({"status": "Iniciando Procesamiento de Acuse COVES", "task_id": task.id}, status=status.HTTP_202_ACCEPTED) + else: + return Response({"status": "Servicio API respondió con error", "task_id": 0}, status=status.HTTP_202_ACCEPTED) + except Exception as e: + return Response({"error": f"Error inesperado al llamar al servicio API: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + @action(detail=True, methods=['post'], url_path='procesar-edocuments') + def procesar_edocs(self, request, pk=None): + """ + Acción para disparar el procesamiento de un edocuments de un pedimento existente. + Dispara la tarea `procesar_edocuments_individual` de forma asíncrona + y devuelve el `task_id`. + """ + pedimento = self.get_object() + try: + # Usar el nombre del servicio de Docker Compose en lugar de localhost + from api.customs.tasks import microservice_v2 + + # Usar el nombre del servicio de Docker Compose en lugar de localhost + task = microservice_v2.procesar_edocs_pedimento.delay(pedimento.id) + # Verificar si la respuesta fue exitosa + if task.id: + return Response({"status": "Iniciando Procesamiento de EDOCS", "task_id": task.id}, status=status.HTTP_202_ACCEPTED) + else: + return Response({"status": "Servicio API respondió con error", "task_id": 0}, status=status.HTTP_202_ACCEPTED) + except Exception as e: + return Response({"error": f"Error inesperado al llamar al servicio API: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + @action(detail=True, methods=['post'], url_path='procesar-acuses') + def procesar_acuses(self, request, pk=None): + """ + Acción para disparar el procesamiento de un acuses de un pedimento existente. + Dispara la tarea `procesar_acuses_individual` de forma asíncrona + y devuelve el `task_id`. + """ + pedimento = self.get_object() + try: + from api.customs.tasks import microservice_v2 + # Usar el nombre del servicio de Docker Compose en lugar de localhost + task = microservice_v2.procesar_acuses_pedimento.delay(pedimento.id) + # Verificar si la respuesta fue exitosa + if task.id: + return Response({"status": "Iniciando Procesamiento de Acuses", "task_id": task.id}, status=status.HTTP_202_ACCEPTED) + else: + return Response({"status": "Servicio API respondió con error", "task_id": 0}, status=status.HTTP_202_ACCEPTED) + except Exception as e: + return Response({"error": f"Error inesperado al llamar al servicio API: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + @action(detail=True, methods=['post'], url_path='procesar-remesas') + def procesar_remesas(self, request, pk=None): + """ + Acción para disparar el procesamiento de remesas de un pedimento existente. + Dispara la tarea `procesar_remesas_pedimento` de forma asíncrona + y devuelve el `task_id`. + """ + pedimento = self.get_object() + try: + from api.customs.tasks import microservice_v2 + task = microservice_v2.procesar_remesas_pedimento.delay(pedimento.id) + if task.id: + return Response({"status": "Iniciando Procesamiento de Remesas", "task_id": task.id}, status=status.HTTP_202_ACCEPTED) + else: + return Response({"status": "Servicio API respondió con error", "task_id": 0}, status=status.HTTP_202_ACCEPTED) + except Exception as e: + return Response({"error": f"Error inesperado al llamar al servicio API: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + @action(detail=False, methods=['post'], url_path='bulk-delete') def bulk_delete(self, request): import traceback @@ -657,11 +782,7 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada "contribuyente": existing_pedimento.contribuyente.rfc if existing_pedimento.contribuyente else None, "archivo_original": archivo.name }) - # NO procesamos este archivo, pasamos al siguiente - continue - - # Si el pedimento no existe, continuar con el procesamiento normal - print("📝 Pedimento no existe, continuando con procesamiento...") + # Continuar al procesamiento de documentos del pedimento existente # Crear subdirectorio para cada archivo usando el nombre del archivo sin extensión sub_dir = os.path.join(temp_dir, archivo_name_sin_extension) @@ -713,56 +834,59 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada f.write(chunk) print(f"Archivo individual {archivo.name} guardado en sub_dir:", archivo_path) - # Ahora crear el pedimento (ya verificamos que no existe) - 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), - pedimento=pedimento_num, - aduana=aduana, - # aduana=int(aduana), - # patente=int(patente), - patente=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, - "archivo_original": archivo.name - }) - - except Exception as e: - print(f"❌ Error al crear pedimento: {str(e)}") - failed_files.append({ - "archivo_original": archivo.name, - "error": f"Error al crear pedimento: {str(e)}" - }) - continue + if existing_pedimento: + pedimento = existing_pedimento + else: + # Crear el pedimento nuevo + 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), + pedimento=pedimento_num, + aduana=aduana, + # aduana=int(aduana), + # patente=int(patente), + patente=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, + "archivo_original": archivo.name + }) + + except Exception as e: + print(f"❌ Error al crear pedimento: {str(e)}") + failed_files.append({ + "archivo_original": archivo.name, + "error": f"Error al crear pedimento: {str(e)}" + }) + continue # Procesar documentos dentro del directorio print("Procesando documentos del directorio...") @@ -2248,6 +2372,7 @@ class ViewSetEDocument(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada serializer.save() return + print(f"self.request.user.groups >>>> {self.request.user.groups}") if (self.request.user.groups.filter(name='developer').exists() or self.request.user.groups.filter(name='admin').exists() or self.request.user.groups.filter(name='user').exists()) and self.request.user.groups.filter(name='Agente Aduanal').exists(): # Para usuarios normales, usar siempre su organización if not hasattr(self.request.user, 'organizacion') or not self.request.user.organizacion: @@ -2355,6 +2480,15 @@ class ImportadorViewSet(viewsets.ModelViewSet, OrganizacionFiltradaMixin): model = Importador def get_queryset(self): + user = self.request.user + grupos = user.groups.values_list('name', flat=True) + + if user.is_superuser: + return Importador.objects.all() + + if 'Importador' in grupos: + return user.rfc.all() + return self.get_queryset_filtrado_por_organizacion() def perform_create(self, serializer): @@ -2889,7 +3023,7 @@ def extract_django_suffix(filename): """ name_without_ext = os.path.splitext(filename)[0] - match = re.search(r'_([a-zA-Z0-9]{7})$', name_without_ext) + match = re.search(r'_([a-zA-Z0-9]{8})$', name_without_ext) if match: return match.group(1) return None @@ -2900,10 +3034,10 @@ def get_clean_base_filename(filename): """ normalized = normalize_filename(filename) name_without_ext, ext = os.path.splitext(normalized) - + django_suffix = extract_django_suffix(name_without_ext) if django_suffix: - base_name = name_without_ext[:-8] + base_name = name_without_ext[:-9] # elimina _XXXXXXXX (underscore + 8 chars UUID) else: base_name = name_without_ext diff --git a/api/record/views.py b/api/record/views.py index 5977d63..92e2cb6 100644 --- a/api/record/views.py +++ b/api/record/views.py @@ -273,6 +273,9 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin): if ruta: documento.archivo = ruta documento.save() + # si no agrego esto, el proceso no retorna todos los campos necesarios como id, si lo agrega a minIO pero no + # actualiza su status. + serializer.instance = documento else: documento.delete() raise ValidationError({"archivo": "Error al guardar el archivo"}) @@ -1320,10 +1323,16 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin): }, "codigo": "bulk_storage_limit_exceeded" }, status=status.HTTP_400_BAD_REQUEST) - + + # Cargar documentos existentes del pedimento para detectar y reemplazar duplicados + existing_docs = list(Document.objects.filter( + pedimento_id=pedimento_id, + organizacion=organizacion + )) + # Procesar cada archivo espacio_usado_temp = espacio_inicial - + for file in files: try: # Validaciones por archivo @@ -1331,37 +1340,67 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin): 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, - size=file.size, - extension=extension - ) - ruta = storage_service.save_document( - file=file, - organizacion_id=organizacion.id, - pedimento_app=pedimento.pedimento_app, - metadata={'source': 'bulk_upload'} - ) + # Detectar si ya existe un documento con el mismo nombre base + extensión. + # storage_service agrega un sufijo UUID de 8 chars al guardar, hay que ignorarlo. + new_name_base = re.sub(r'_[a-zA-Z0-9]{8}$', '', os.path.splitext(file.name)[0]).lower().strip('_') + existing_doc = None + for doc in existing_docs: + if doc.archivo: + doc_basename = os.path.basename(doc.archivo.name) + doc_base = re.sub(r'_[a-zA-Z0-9]{8}$', '', os.path.splitext(doc_basename)[0]).lower().strip('_') + doc_ext = (doc.extension or '').lower() + if new_name_base == doc_base and extension == doc_ext: + existing_doc = doc + break - if ruta: - document.archivo = ruta - document.save() + if existing_doc: + # Reemplazar archivo del documento existente + if existing_doc.archivo: + storage_service.delete_file(existing_doc.archivo.name) + ruta = storage_service.save_document( + file=file, + organizacion_id=organizacion.id, + pedimento_app=pedimento.pedimento_app, + metadata={'source': 'bulk_upload_replace'} + ) + if ruta: + existing_doc.archivo = ruta + existing_doc.size = file.size + existing_doc.extension = extension + existing_doc.save() + else: + raise Exception(f"Error al guardar archivo: {file.name}") + document = existing_doc else: - document.delete() - raise Exception(f"Error al guardar archivo: {file.name}") - + # Crear nuevo documento + document = Document.objects.create( + organizacion=organizacion, + pedimento_id=pedimento_id, + document_type=document_type, + size=file.size, + extension=extension + ) + ruta = storage_service.save_document( + file=file, + organizacion_id=organizacion.id, + pedimento_app=pedimento.pedimento_app, + metadata={'source': 'bulk_upload'} + ) + if ruta: + document.archivo = ruta + document.save() + else: + document.delete() + raise Exception(f"Error al guardar archivo: {file.name}") + # Actualizar espacio usado espacio_usado_temp += file.size total_space_used += file.size - + uploaded_documents.append({ "id": str(document.id), "filename": file.name, @@ -1369,12 +1408,12 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin): "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() diff --git a/api/reports/views.py b/api/reports/views.py index d21b5f3..4e5787f 100644 --- a/api/reports/views.py +++ b/api/reports/views.py @@ -135,6 +135,33 @@ class ExportDataStageView(APIView): else: return str(value) + def get(self, request, *args, **kwargs): + """Retorna RFCs distintos de Registro501 para la organización indicada. El parámetro organizacion es obligatorio.""" + try: + Registro501 = apps.get_model('datastage', 'Registro501') + + if not request.user.is_superuser: + qs = Registro501.objects.filter(organizacion=request.user.organizacion) + else: + org_id = request.query_params.get('organizacion') + if not org_id: + return Response({'error': 'El parámetro organizacion es obligatorio'}, status=status.HTTP_400_BAD_REQUEST) + try: + qs = Registro501.objects.filter(organizacion_id=uuid.UUID(org_id)) + except (ValueError, AttributeError): + return Response({'error': 'UUID de organización inválido'}, status=status.HTTP_400_BAD_REQUEST) + + rfcs = ( + qs.exclude(rfc__isnull=True) + .exclude(rfc='') + .values_list('rfc', flat=True) + .distinct() + .order_by('rfc') + ) + return Response({'rfcs': list(rfcs)}) + except LookupError: + return Response({'rfcs': []}) + @swagger_auto_schema(request_body=ExportModelSerializer, responses={200: 'Archivo generado (Excel o CSV)'}) def post(self, request, *args, **kwargs): """ @@ -148,6 +175,27 @@ class ExportDataStageView(APIView): else: return self.handle_simple_export(request) + def _resolve_org_filter(self, global_filters, user): + """ + Devuelve los global_filters asegurando que siempre haya una organización. + - Superuser sin org → error (no mezclar tenants). + - No-superuser sin org → se inyecta la org del usuario. + Retorna (filters_dict, error_response_or_None). + """ + org_value = (global_filters or {}).get('organizacion', '') + if not org_value: + if user.is_superuser: + return None, Response( + {'error': 'El parámetro organizacion es obligatorio'}, + status=status.HTTP_400_BAD_REQUEST + ) + # No-superuser: inyectar su propia org + if hasattr(user, 'organizacion') and user.organizacion: + filters = dict(global_filters or {}) + filters['organizacion'] = str(user.organizacion.id) + return filters, None + return dict(global_filters or {}), None + def handle_simple_export(self, request): """Maneja exportación simple de DataStage (un solo modelo)""" model_name = request.data.get('model') @@ -159,6 +207,10 @@ class ExportDataStageView(APIView): if not model_name or not fields: return Response({'error': 'model and fields are required'}, status=status.HTTP_400_BAD_REQUEST) + global_filters, err = self._resolve_org_filter(global_filters, request.user) + if err: + return err + try: model = apps.get_model(module, model_name) filters = self.apply_global_filters_to_model(global_filters, model, request.user) @@ -190,18 +242,16 @@ class ExportDataStageView(APIView): if not models_data: return Response({'error': 'models are required for multiple export'}, status=status.HTTP_400_BAD_REQUEST) + global_filters, err = self._resolve_org_filter(global_filters, request.user) + if err: + return err + related_keys = self.get_related_keys_from_filters(global_filters, models_data, request.user) if export_type == 'excel': - # Siempre usar el método particionado inteligente para Excel return self.export_datastage_multiple_partitioned_excel_agrupados(request, models_data, global_filters, related_keys) else: - # Para CSV, podemos mantener la lógica actual o mejorarla - total_estimated_records = self.estimate_total_records(models_data, global_filters, related_keys, request.user) - if total_estimated_records > self.MAX_RECORDS_PER_FILE: - return self.export_datastage_multiple_partitioned_csv(request, models_data, global_filters, related_keys) - else: - return self.export_datastage_multiple_to_csv(request, models_data, global_filters, related_keys) + return self.export_datastage_multiple_to_csv_combined(request, models_data, global_filters, related_keys) def estimate_total_records(self, models_data, global_filters, related_keys, user): """Estima el total de registros para todos los modelos""" @@ -282,292 +332,231 @@ class ExportDataStageView(APIView): def export_datastage_multiple_partitioned_excel_agrupados(self, request, models_data, global_filters, related_keys): """Exporta múltiples modelos de DataStage agrupados en la misma hoja de Excel, con particionado por límite de registros""" try: - zip_buffer = io.BytesIO() - - # 🔥 PRECARGAR ORGANIZACIONES para mapeo rápido from api.organization.models import Organizacion - organizaciones = Organizacion.objects.all() - org_mapping = {str(org.id): org.nombre for org in organizaciones} + org_mapping = {str(org.id): org.nombre for org in Organizacion.objects.all()} - with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file: + # 1. Recopilar todos los datos FUERA del contexto ZIP + all_models_data = {} + model_field_mappings = {} - # 1. Recopilar todos los datos de cada modelo - all_models_data = {} # Ahora será una lista por clave - model_field_mappings = {} + for model_data in models_data: + model_name = model_data.get('model') + fields = model_data.get('fields', []) - for model_data in models_data: - model_name = model_data.get('model') - fields = model_data.get('fields', []) - - if not model_name or not fields: - continue - - # Normalizar nombres de campo entrantes: si se pasó "Organizacion" - # (cualquier capitalización), usar el campo real de la BD `organizacion_id`. - normalized_fields = [] - for f in fields: - try: - key = f.strip() if isinstance(f, str) else f - except Exception: - key = f - - if isinstance(key, str) and key.lower() == 'organizacion': - if 'organizacion_id' not in normalized_fields: - normalized_fields.append('organizacion_id') - else: - if key not in normalized_fields: - normalized_fields.append(key) - - fields = normalized_fields - - # Asegurar que tenemos los campos de relación - required_fields = ['seccion_aduanera', 'patente', 'pedimento'] - for field in required_fields: - if field not in fields: - fields.append(field) - - # 🔥 Añadir organizacion_id a los campos si no está y existe en el modelo - if 'organizacion_id' not in fields and 'organizacion_id' in [f.name for f in apps.get_model('datastage', model_name)._meta.get_fields()]: - fields.append('organizacion_id') + if not model_name or not fields: + continue + normalized_fields = [] + for f in fields: try: - model = apps.get_model('datastage', model_name) - filters = self.apply_related_filters(global_filters, model, related_keys, request.user) + key = f.strip() if isinstance(f, str) else f + except Exception: + key = f - if filters: - queryset = model.objects.filter(**filters).values(*fields) - else: - queryset = model.objects.none() + if isinstance(key, str) and key.lower() == 'organizacion': + if 'organizacion_id' not in normalized_fields: + normalized_fields.append('organizacion_id') + else: + if key not in normalized_fields: + normalized_fields.append(key) - total_records = queryset.count() + fields = normalized_fields - if total_records == 0: - continue - - # Determinar campos de relación disponibles en este modelo - relation_fields = [] - for field_name in ['seccion_aduanera', 'patente', 'pedimento']: - if field_name in fields: - relation_fields.append(field_name) + required_fields = ['seccion_aduanera', 'patente', 'pedimento'] + for field in required_fields: + if field not in fields: + fields.append(field) - if not relation_fields: - # Si no hay campos de relación, usar un identificador único - relation_fields = ['datastage_id'] if 'datastage_id' in fields else [fields[0]] + if 'organizacion_id' not in fields and 'organizacion_id' in [f.name for f in apps.get_model('datastage', model_name)._meta.get_fields()]: + fields.append('organizacion_id') - # Guardar mapeo de campos para este modelo - if model_name not in model_field_mappings: - model_field_mappings[model_name] = fields + try: + model = apps.get_model('datastage', model_name) + filters = self.apply_related_filters(global_filters, model, related_keys, request.user) - # Procesar cada registro - for record in queryset: - # Crear clave de relación - key_parts = [] - for rel_field in relation_fields: - if rel_field in record and record[rel_field] is not None: - key_parts.append(str(record[rel_field])) + if filters: + queryset = model.objects.filter(**filters).values(*fields) + else: + queryset = model.objects.none() - if not key_parts: - # Si no hay campos de relación, usar un hash del registro - import hashlib - record_str = str(sorted(record.items())) - key = hashlib.md5(record_str.encode()).hexdigest()[:10] - else: - key = "_".join(key_parts) - - # 🔥 PROCESAR CAMPO organizacion_id para convertirlo a nombre - processed_record = {} - for field_name, value in record.items(): - # Convertir organizacion_id a nombre - if field_name == 'organizacion_id' and value: - org_id_str = str(value) - # Usar el nombre de la organización si está en el mapeo - if org_id_str in org_mapping: - processed_value = org_mapping[org_id_str] - else: - # Si no se encuentra, intentar obtener de la base de datos - try: - org = Organizacion.objects.filter(id=value).first() - processed_value = org.nombre if org else str(value) - # Actualizar mapeo para futuras referencias - org_mapping[org_id_str] = processed_value - except: - processed_value = str(value) - else: - processed_value = value - - # Agregar prefijo del modelo a los campos para evitar colisiones - if field_name in relation_fields: - prefixed_field_name = field_name - else: - prefixed_field_name = f"{model_name}_{field_name}" - - # 🔥 RENOMBRAR organizacion_id a organizacion_nombre - if field_name == 'organizacion_id': - prefixed_field_name = prefixed_field_name.replace('organizacion_id', 'organizacion_nombre') - - processed_record[prefixed_field_name] = self.safe_excel_value(processed_value) - - # 🔥 CORRECIÓN: Ahora almacenamos una LISTA de registros por clave - if key not in all_models_data: - all_models_data[key] = { - 'relation_fields': {}, # Campos de relación compartidos - 'model_records': {} # Diccionario de listas por modelo - } - - # Guardar campos de relación (solo una vez, ya que son los mismos) - for rel_field in relation_fields: - if rel_field in record: - all_models_data[key]['relation_fields'][rel_field] = record[rel_field] - - # 🔥 GUARDAR COMO LISTA: Crear lista si no existe - if model_name not in all_models_data[key]['model_records']: - all_models_data[key]['model_records'][model_name] = [] - - # Agregar este registro a la lista del modelo - all_models_data[key]['model_records'][model_name].append(processed_record) - - except LookupError: + if queryset.count() == 0: continue - - # Si no hay datos, retornar error - if not all_models_data: - return Response({'error': 'No se encontraron datos para exportar'}, status=status.HTTP_404_NOT_FOUND) - # 2. Crear estructura de filas combinadas - # Ahora necesitamos expandir las filas cuando hay múltiples registros con la misma clave - combined_rows = [] + relation_fields = [fn for fn in ['seccion_aduanera', 'patente', 'pedimento'] if fn in fields] + if not relation_fields: + relation_fields = ['datastage_id'] if 'datastage_id' in fields else [fields[0]] - for key, data in all_models_data.items(): - relation_fields = data['relation_fields'] - model_records = data['model_records'] + if model_name not in model_field_mappings: + model_field_mappings[model_name] = fields - # 🔥 NUEVO: Calcular cuántas filas necesitamos para esta clave - # Encontrar el modelo con más registros para esta clave - max_records_per_key = 1 - for model_name, records in model_records.items(): - if len(records) > max_records_per_key: - max_records_per_key = len(records) + for record in queryset: + key_parts = [str(record[rf]) for rf in relation_fields if rf in record and record[rf] is not None] + if not key_parts: + import hashlib + key = hashlib.md5(str(sorted(record.items())).encode()).hexdigest()[:10] + else: + key = "_".join(key_parts) - # 🔗 CREAR UNA FILA POR CADA COMBINACIÓN - for i in range(max_records_per_key): - row_data = {} - - # Campos de relación (mismos para todas las filas con esta clave) - for rel_field, rel_value in relation_fields.items(): - row_data[rel_field] = self.safe_excel_value(rel_value) - - # Datos de cada modelo - for model_name, records in model_records.items(): - # Si hay un registro en esta posición i - if i < len(records): - record = records[i] - for field_name, value in record.items(): - row_data[field_name] = value + processed_record = {} + for field_name, value in record.items(): + if field_name == 'organizacion_id' and value: + org_id_str = str(value) + if org_id_str in org_mapping: + processed_value = org_mapping[org_id_str] + else: + try: + org = Organizacion.objects.filter(id=value).first() + processed_value = org.nombre if org else org_id_str + org_mapping[org_id_str] = processed_value + except Exception: + processed_value = org_id_str else: - # Si no hay más registros para este modelo, poner campos vacíos - for field_name in model_field_mappings.get(model_name, []): - if field_name in ['seccion_aduanera', 'patente', 'pedimento', 'organizacion_id']: - # Los campos de relación ya están llenados o transformados - continue - prefixed_field_name = f"{model_name}_{field_name}" - # 🔥 RENOMBRAR organizacion_id a organizacion_nombre - if field_name == 'organizacion_id': - prefixed_field_name = prefixed_field_name.replace('organizacion_id', 'organizacion_nombre') - row_data[prefixed_field_name] = '' + processed_value = value - combined_rows.append(row_data) + if field_name in relation_fields: + prefixed_field_name = field_name + else: + prefixed_field_name = f"{model_name}_{field_name}" - # 3. Determinar todos los campos únicos para los encabezados - all_fields_set = set() + if field_name == 'organizacion_id': + prefixed_field_name = prefixed_field_name.replace('organizacion_id', 'organizacion_nombre') - # Campos de relación primero - common_relation_fields = ['seccion_aduanera', 'patente', 'pedimento'] + processed_record[prefixed_field_name] = self.safe_excel_value(processed_value) - # Agregar todos los campos de todas las filas - for row in combined_rows: - all_fields_set.update(row.keys()) + if key not in all_models_data: + all_models_data[key] = {'relation_fields': {}, 'model_records': {}} - # Ordenar campos: relación primero, luego alfabéticamente - all_fields = [] - for rel_field in common_relation_fields: - if rel_field in all_fields_set: - all_fields.append(rel_field) - all_fields_set.remove(rel_field) + for rel_field in relation_fields: + if rel_field in record: + all_models_data[key]['relation_fields'][rel_field] = record[rel_field] - # 🔥 Mover organizacion_nombre cerca de los campos de relación - org_fields = [f for f in all_fields_set if 'organizacion' in f.lower()] - for org_field in sorted(org_fields): - all_fields.append(org_field) - all_fields_set.remove(org_field) + if model_name not in all_models_data[key]['model_records']: + all_models_data[key]['model_records'][model_name] = [] - # Agregar el resto de campos ordenados alfabéticamente - all_fields.extend(sorted(all_fields_set)) + all_models_data[key]['model_records'][model_name].append(processed_record) - total_records = len(combined_rows) + except LookupError: + continue - # 4. Manejar particionado - from django.core.paginator import Paginator - paginator = Paginator(combined_rows, self.MAX_RECORDS_PER_FILE) + # 2. Sin datos → Excel vacío (no JSON 404 que rompe la descarga en el frontend) + if not all_models_data: + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "Sin datos" + ws.append(["No se encontraron datos para los filtros especificados"]) + output = io.BytesIO() + wb.save(output) + output.seek(0) + resp = HttpResponse( + output.read(), + content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ) + resp['Content-Disposition'] = 'attachment; filename="datastage_sin_datos.xlsx"' + return resp + # 3. Construir filas combinadas — repetir el último registro en lugar de dejar vacíos + combined_rows = [] + for key, data in all_models_data.items(): + relation_fields_data = data['relation_fields'] + model_records = data['model_records'] + + max_records_per_key = max((len(recs) for recs in model_records.values()), default=1) + + for i in range(max_records_per_key): + row_data = {} + + for rel_field, rel_value in relation_fields_data.items(): + row_data[rel_field] = self.safe_excel_value(rel_value) + + for model_name, records in model_records.items(): + # Usar posición i o el último registro disponible + record = records[i] if i < len(records) else records[-1] + for field_name, value in record.items(): + row_data[field_name] = value + + combined_rows.append(row_data) + + # 4. Encabezados ordenados + all_fields_set = set() + for row in combined_rows: + all_fields_set.update(row.keys()) + + all_fields = [] + for rel_field in ['seccion_aduanera', 'patente', 'pedimento']: + if rel_field in all_fields_set: + all_fields.append(rel_field) + all_fields_set.discard(rel_field) + + org_fields = sorted(f for f in all_fields_set if 'organizacion' in f.lower()) + for org_field in org_fields: + all_fields.append(org_field) + all_fields_set.discard(org_field) + + all_fields.extend(sorted(all_fields_set)) + + # 5. Filas de título y fecha de generación + now_str = datetime.datetime.now().strftime('%d/%m/%Y %H:%M:%S') + title_row = ["Reporte Datastage"] + date_row = [f"Generado: {now_str}"] + + def _write_sheet(ws, sheet_name, page_rows): + ws.title = sheet_name[:31] + ws.append(title_row) + ws.append(date_row) + ws.append([]) + ws.append(all_fields) + for row_data in page_rows: + ws.append([row_data.get(field, '') for field in all_fields]) + for column in ws.columns: + max_length = 0 + col_letter = column[0].column_letter + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except Exception: + pass + ws.column_dimensions[col_letter].width = min(max_length + 2, 50) + + # 6. Excel directo si cabe en un archivo; ZIP solo si se necesita particionar + from django.core.paginator import Paginator + paginator = Paginator(combined_rows, self.MAX_RECORDS_PER_FILE) + + if paginator.num_pages == 1: + wb = openpyxl.Workbook() + _write_sheet(wb.active, "Datastage", paginator.page(1).object_list) + output = io.BytesIO() + wb.save(output) + output.seek(0) + resp = HttpResponse( + output.read(), + content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ) + resp['Content-Disposition'] = 'attachment; filename="datastage_reporte.xlsx"' + return resp + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file: for page_num in paginator.page_range: page = paginator.page(page_num) - - # Crear nuevo workbook para cada partición current_wb = openpyxl.Workbook() - current_ws = current_wb.active - - # Nombre de hoja limitado a 31 caracteres - sheet_name = f"Datastage_p{page_num}" - if len(sheet_name) > 31: - sheet_name = sheet_name[:31] - current_ws.title = sheet_name - - # Escribir encabezados - current_ws.append(all_fields) - - # Escribir datos de esta página - for row_data in page.object_list: - row_values = [row_data.get(field, '') for field in all_fields] - current_ws.append(row_values) - - # Autoajustar anchos de columna - for column in current_ws.columns: - max_length = 0 - column_letter = column[0].column_letter - - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - - adjusted_width = min(max_length + 2, 50) - current_ws.column_dimensions[column_letter].width = adjusted_width - - # Guardar archivo en ZIP + _write_sheet(current_wb.active, f"Datastage_p{page_num}", page.object_list) part_buffer = io.BytesIO() current_wb.save(part_buffer) part_buffer.seek(0) zip_file.writestr(f"datastage_part{page_num}.xlsx", part_buffer.getvalue()) - # Información de depuración - print(f"Creada partición {page_num} con {len(page.object_list)} registros combinados") - print(f"Total de claves únicas: {len(all_models_data)}") - print(f"Total de filas expandidas: {total_records}") - zip_buffer.seek(0) - - response = HttpResponse(zip_buffer.read(), content_type='application/zip') - response['Content-Disposition'] = 'attachment; filename="datastage_combinado.zip"' - return response + resp = HttpResponse(zip_buffer.read(), content_type='application/zip') + resp['Content-Disposition'] = 'attachment; filename="datastage_combinado.zip"' + return resp except Exception as e: import traceback - error_details = traceback.format_exc() - print(f"Error en exportación: {error_details}") + import logging + logging.getLogger(__name__).error("Error en exportación combinada: %s", traceback.format_exc()) return Response({'error': f'Error en exportación combinada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - + def export_datastage_multiple_partitioned_excel_test_3(self, request, models_data, global_filters, related_keys): """Exporta múltiples modelos de DataStage agrupados en la misma hoja de Excel, con particionado por límite de registros""" @@ -782,10 +771,6 @@ class ExportDataStageView(APIView): part_buffer.seek(0) zip_file.writestr(f"datastage_part{page_num}.xlsx", part_buffer.getvalue()) - # Información de depuración - print(f"Creada partición {page_num} con {len(page.object_list)} registros combinados") - print(f"Total de claves únicas: {len(all_models_data)}") - print(f"Total de filas expandidas: {total_records}") zip_buffer.seek(0) @@ -795,12 +780,11 @@ class ExportDataStageView(APIView): except Exception as e: import traceback - error_details = traceback.format_exc() - print(f"Error en exportación: {error_details}") + import logging + logging.getLogger(__name__).error("Error en exportación combinada: %s", traceback.format_exc()) return Response({'error': f'Error en exportación combinada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - def export_datastage_multiple_partitioned_excel_test_2(self, request, models_data, global_filters, related_keys): """Exporta múltiples modelos de DataStage agrupados en la misma hoja de Excel, con particionado por límite de registros""" try: @@ -1009,9 +993,9 @@ class ExportDataStageView(APIView): except Exception as e: import traceback - error_details = traceback.format_exc() - print(f"Error en exportación: {error_details}") - return Response({'error': f'Error en exportación combinada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + import logging + logging.getLogger(__name__).error("Error en exportación combinada: %s", traceback.format_exc()) + return Response({'error': f'Error en exportación combinada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) def export_datastage_multiple_partitioned_excel_test(self, request, models_data, global_filters, related_keys): @@ -1126,8 +1110,6 @@ class ExportDataStageView(APIView): part_buffer.seek(0) zip_file.writestr(f"datastage_combinado_part{page_num}.xlsx", part_buffer.getvalue()) - # Información de depuración (opcional) - print(f"Creada partición {page_num} con {len(page.object_list)} registros") zip_buffer.seek(0) @@ -1137,9 +1119,9 @@ class ExportDataStageView(APIView): except Exception as e: import traceback - error_details = traceback.format_exc() - print(f"Error en exportación: {error_details}") - return Response({'error': f'Error en exportación combinada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + import logging + logging.getLogger(__name__).error("Error en exportación combinada: %s", traceback.format_exc()) + return Response({'error': f'Error en exportación combinada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) def export_datastage_multiple_partitioned_excel(self, request, models_data, global_filters, related_keys): """Exporta múltiples modelos de DataStage a múltiples archivos Excel particionados inteligentemente""" @@ -1265,6 +1247,144 @@ class ExportDataStageView(APIView): except Exception as e: return Response({'error': f'Error en exportación particionada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + def export_datastage_multiple_to_csv_combined(self, request, models_data, global_filters, related_keys): + """Exporta múltiples modelos combinados en un único CSV plano (misma lógica de agrupación que el Excel).""" + import hashlib + import logging + import traceback + logger = logging.getLogger(__name__) + try: + from api.organization.models import Organizacion + org_mapping = {str(org.id): org.nombre for org in Organizacion.objects.all()} + + all_models_data = {} + model_field_mappings = {} + + for model_data in models_data: + model_name = model_data.get('model') + fields = model_data.get('fields', []) + if not model_name or not fields: + continue + + normalized_fields = [] + for f in fields: + key = f.strip() if isinstance(f, str) else f + if isinstance(key, str) and key.lower() == 'organizacion': + if 'organizacion_id' not in normalized_fields: + normalized_fields.append('organizacion_id') + else: + if key not in normalized_fields: + normalized_fields.append(key) + fields = normalized_fields + + for req_field in ['seccion_aduanera', 'patente', 'pedimento']: + if req_field not in fields: + fields.append(req_field) + + try: + model = apps.get_model('datastage', model_name) + model_field_names = [f.name for f in model._meta.get_fields() if hasattr(f, 'name')] + if 'organizacion_id' not in fields and 'organizacion_id' in model_field_names: + fields.append('organizacion_id') + + filters = self.apply_related_filters(global_filters, model, related_keys, request.user) + queryset = model.objects.filter(**filters).values(*fields) if filters else model.objects.none() + if queryset.count() == 0: + continue + + relation_fields = [fn for fn in ['seccion_aduanera', 'patente', 'pedimento'] if fn in fields] + if not relation_fields: + relation_fields = ['datastage_id'] if 'datastage_id' in fields else [fields[0]] + + if model_name not in model_field_mappings: + model_field_mappings[model_name] = fields + + for record in queryset: + key_parts = [str(record[rf]) for rf in relation_fields if rf in record and record[rf] is not None] + key = "_".join(key_parts) if key_parts else hashlib.md5(str(sorted(record.items())).encode()).hexdigest()[:10] + + processed_record = {} + for field_name, value in record.items(): + if field_name == 'organizacion_id' and value: + org_id_str = str(value) + processed_value = org_mapping.get(org_id_str, org_id_str) + else: + processed_value = value + + if field_name in relation_fields: + prefixed = field_name + else: + prefixed = f"{model_name}_{field_name}" + if field_name == 'organizacion_id': + prefixed = prefixed.replace('organizacion_id', 'organizacion_nombre') + processed_record[prefixed] = self.safe_excel_value(processed_value) + + if key not in all_models_data: + all_models_data[key] = {'relation_fields': {}, 'model_records': {}} + for rel_field in relation_fields: + if rel_field in record: + all_models_data[key]['relation_fields'][rel_field] = record[rel_field] + if model_name not in all_models_data[key]['model_records']: + all_models_data[key]['model_records'][model_name] = [] + all_models_data[key]['model_records'][model_name].append(processed_record) + + except LookupError: + continue + + # Sin datos → CSV con mensaje, no error HTTP + if not all_models_data: + buf = io.StringIO() + csv.writer(buf).writerow(['No se encontraron datos para los filtros especificados']) + resp = HttpResponse(buf.getvalue(), content_type='text/csv; charset=utf-8') + resp['Content-Disposition'] = 'attachment; filename="datastage_sin_datos.csv"' + return resp + + # Construir filas planas + combined_rows = [] + for key, data in all_models_data.items(): + relation_fields_data = data['relation_fields'] + model_records = data['model_records'] + max_records = max((len(recs) for recs in model_records.values()), default=1) + for i in range(max_records): + row_data = {} + for rel_field, rel_value in relation_fields_data.items(): + row_data[rel_field] = self.safe_excel_value(rel_value) + for mn, records in model_records.items(): + record = records[i] if i < len(records) else records[-1] + for field_name, value in record.items(): + row_data[field_name] = value + combined_rows.append(row_data) + + # Encabezados: campos de relación primero, luego org, luego el resto + all_fields_set = set() + for row in combined_rows: + all_fields_set.update(row.keys()) + + all_fields = [] + for rel_field in ['seccion_aduanera', 'patente', 'pedimento']: + if rel_field in all_fields_set: + all_fields.append(rel_field) + all_fields_set.discard(rel_field) + org_fields = sorted(f for f in all_fields_set if 'organizacion' in f.lower()) + for org_field in org_fields: + all_fields.append(org_field) + all_fields_set.discard(org_field) + all_fields.extend(sorted(all_fields_set)) + + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow(all_fields) + for row_data in combined_rows: + writer.writerow([row_data.get(field, '') for field in all_fields]) + + resp = HttpResponse(buf.getvalue(), content_type='text/csv; charset=utf-8') + resp['Content-Disposition'] = 'attachment; filename="datastage_reporte.csv"' + return resp + + except Exception as e: + logger.error("Error en exportación CSV combinada: %s", traceback.format_exc()) + return Response({'error': f'Error en exportación CSV combinada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + def export_datastage_multiple_to_csv(self, request, models_data, global_filters, related_keys): """Exporta múltiples modelos de DataStage a múltiples archivos CSV en ZIP""" zip_buffer = io.BytesIO() @@ -1472,57 +1592,56 @@ class ExportDataStageView(APIView): def get_related_keys_from_filters(self, global_filters, models_data, user): """ - Obtiene patentes, pedimentos y datastages que cumplen EXACTAMENTE con TODOS los filtros globales - VERSIÓN SIMPLIFICADA - Usa la MISMA lógica que apply_global_filters_to_model + Construye el conjunto de (patente, pedimento, datastage_id) que servirá como + llave de cruce entre modelos. + + Regla clave: si el filtro RFC está activo, solo los modelos que tienen el campo + 'rfc' pueden contribuir a related_keys. Los modelos sin 'rfc' (ej. 505, 506) + no se usan como semilla — solo se filtrarán más tarde usando las claves ya + construidas, evitando que contaminen el resultado con pedimentos de otros RFC. """ related_keys = { 'patentes': set(), 'pedimentos': set(), 'datastage_ids': set() } - - # Si no hay filtros, retornar vacío + + # Sin filtros significativos → sin cruce if not any(v for v in global_filters.values() if v not in [None, '']): return {} - + + rfc_filter_active = bool(global_filters.get('rfc')) + date_filter_active = bool(global_filters.get('fecha_pago_desde') or global_filters.get('fecha_pago_hasta')) all_records_with_filters = [] for model_data in models_data: model_name = model_data.get('model') - try: model = apps.get_model('datastage', model_name) - - # ¡USAR LA MISMA FUNCIÓN QUE EN MODO SINGULAR! + model_field_names = {f.name for f in model._meta.get_fields() if hasattr(f, 'name')} + + # Un modelo puede ser semilla de related_keys SOLO si tiene campos + # para aplicar TODOS los filtros activos. Un modelo sin 'rfc' no puede + # ser semilla cuando hay filtro de RFC (contaminaría con pedimentos de + # otros RFCs). Igual para fecha_pago_real cuando hay filtro de fechas. + if rfc_filter_active and 'rfc' not in model_field_names: + continue + if date_filter_active and 'fecha_pago_real' not in model_field_names: + continue + filters = self.apply_global_filters_to_model(global_filters, model, user) - - if filters: - # EJECUTAR CONSULTA - IDÉNTICO A MODO SINGULAR - queryset = model.objects.filter(**filters) - total = queryset.count() - - # VERIFICACIÓN ESPECIAL PARA RFC - if 'rfc' in filters: - rfc_value = filters['rfc'] - # Doble verificación: contar registros con ese RFC exacto - rfc_exact_count = queryset.filter(rfc=rfc_value).count() - - if rfc_exact_count != total: - try: - other_rfcs = queryset.exclude(rfc=rfc_value).values_list('rfc', flat=True).distinct()[:5] - except: - pass - - # Obtener registros - records = queryset.values('patente', 'pedimento', 'datastage_id') - all_records_with_filters.extend(list(records)) - + if not filters: + continue + + records = model.objects.filter(**filters).values('patente', 'pedimento', 'datastage_id') + all_records_with_filters.extend(list(records)) + except LookupError: continue - + if not all_records_with_filters: return {'patentes': set(), 'pedimentos': set(), 'datastage_ids': set()} - + for record in all_records_with_filters: if record.get('patente'): related_keys['patentes'].add(record['patente']) @@ -1530,7 +1649,7 @@ class ExportDataStageView(APIView): related_keys['pedimentos'].add(record['pedimento']) if record.get('datastage_id'): related_keys['datastage_ids'].add(record['datastage_id']) - + return {k: list(v) for k, v in related_keys.items() if v} def apply_global_filters_to_model(self, global_filters, model, user): @@ -1585,9 +1704,17 @@ class ExportDataStageView(APIView): filters = {} model_fields = [f.name for f in model._meta.get_fields()] - # 1. Organización + # 1. Organización — convertir a UUID igual que apply_global_filters_to_model if 'organizacion' in model_fields and global_filters.get('organizacion'): - filters['organizacion'] = global_filters['organizacion'] + org_value = global_filters['organizacion'] + try: + field = model._meta.get_field('organizacion') + if hasattr(field, 'related_model'): + filters['organizacion_id'] = uuid.UUID(org_value) + else: + filters['organizacion'] = org_value + except Exception: + filters['organizacion_id'] = org_value # 2. RFC (¡ESTO ES LO QUE FALTA!) if 'rfc' in model_fields and global_filters.get('rfc'):