feature/T2026-05-016-y-T2026-05-031 #28

Merged
jcedilloAS merged 4 commits from feature/T2026-05-016-y-T2026-05-031 into main 2026-05-18 18:05:27 +00:00
4 changed files with 707 additions and 407 deletions
Showing only changes of commit c2ae752932 - Show all commits

View File

@@ -27,16 +27,27 @@ def normalize_filename(filename):
return filename return filename
def extract_django_suffix(filename):
"""
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]{8})$', name_without_ext)
if match:
return match.group(1)
return None
def get_clean_base_filename(filename): def get_clean_base_filename(filename):
""" """
Obtiene el nombre base limpio sin el sufijo de Django. Obtiene el nombre base limpio sin el sufijo UUID de storage_service.
""" """
normalized = normalize_filename(filename) normalized = normalize_filename(filename)
name_without_ext, ext = os.path.splitext(normalized) name_without_ext, ext = os.path.splitext(normalized)
django_suffix = extract_django_suffix(name_without_ext) django_suffix = extract_django_suffix(name_without_ext)
if django_suffix: if django_suffix:
base_name = name_without_ext[:-8] base_name = name_without_ext[:-9] # elimina _XXXXXXXX (underscore + 8 chars UUID)
else: else:
base_name = name_without_ext base_name = name_without_ext
@@ -45,17 +56,6 @@ def get_clean_base_filename(filename):
return base_name.lower().strip('_') return base_name.lower().strip('_')
def extract_django_suffix(filename):
"""
Extrae el sufijo único que Django 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)
if match:
return match.group(1)
return None
def is_same_document(existing_doc, new_filename): def is_same_document(existing_doc, new_filename):
""" """
Compara si un documento existente y un nuevo archivo son el mismo documento. Compara si un documento existente y un nuevo archivo son el mismo documento.

View File

@@ -1,3 +1,4 @@
from api.utils.storage_service import storage_service
from config.settings import SERVICE_API_URL from config.settings import SERVICE_API_URL
from django.shortcuts import render from django.shortcuts import render
from rest_framework import viewsets from rest_framework import viewsets
@@ -61,7 +62,6 @@ except ImportError:
# Importar tarea de procesamiento de pedimento (Celery) # Importar tarea de procesamiento de pedimento (Celery)
from api.customs.tasks.microservice import procesar_pedimento_completo_individual from api.customs.tasks.microservice import procesar_pedimento_completo_individual
from api.utils.storage_service import storage_service
def get_available_extractors(): def get_available_extractors():
""" """
@@ -394,6 +394,131 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
except Exception as e: except Exception as e:
return Response({"error": f"Error inesperado al llamar al servicio API: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) 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') @action(detail=False, methods=['post'], url_path='bulk-delete')
def bulk_delete(self, request): def bulk_delete(self, request):
import traceback import traceback
@@ -657,11 +782,7 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
"contribuyente": existing_pedimento.contribuyente.rfc if existing_pedimento.contribuyente else None, "contribuyente": existing_pedimento.contribuyente.rfc if existing_pedimento.contribuyente else None,
"archivo_original": archivo.name "archivo_original": archivo.name
}) })
# NO procesamos este archivo, pasamos al siguiente # Continuar al procesamiento de documentos del pedimento existente
continue
# Si el pedimento no existe, continuar con el procesamiento normal
print("📝 Pedimento no existe, continuando con procesamiento...")
# Crear subdirectorio para cada archivo usando el nombre del archivo sin extensión # Crear subdirectorio para cada archivo usando el nombre del archivo sin extensión
sub_dir = os.path.join(temp_dir, archivo_name_sin_extension) sub_dir = os.path.join(temp_dir, archivo_name_sin_extension)
@@ -713,56 +834,59 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
f.write(chunk) f.write(chunk)
print(f"Archivo individual {archivo.name} guardado en sub_dir:", archivo_path) print(f"Archivo individual {archivo.name} guardado en sub_dir:", archivo_path)
# Ahora crear el pedimento (ya verificamos que no existe) if existing_pedimento:
try: pedimento = existing_pedimento
print("🔄 Iniciando creación de pedimento...") else:
# Crear el pedimento nuevo
try:
print("🔄 Iniciando creación de pedimento...")
# Obtener o crear el importador # Obtener o crear el importador
print(f"🏢 Buscando/creando importador con RFC: {contribuyente}") print(f"🏢 Buscando/creando importador con RFC: {contribuyente}")
importador, created = Importador.objects.get_or_create( importador, created = Importador.objects.get_or_create(
rfc=contribuyente, rfc=contribuyente,
defaults={ defaults={
'nombre': f"Importador {contribuyente}", 'nombre': f"Importador {contribuyente}",
'organizacion': organizacion 'organizacion': organizacion
} }
) )
if created: if created:
print(f"✅ Importador creado: {importador.rfc} - {importador.nombre}") print(f"✅ Importador creado: {importador.rfc} - {importador.nombre}")
else: else:
print(f"♻️ Importador existente: {importador.rfc} - {importador.nombre}") print(f"♻️ Importador existente: {importador.rfc} - {importador.nombre}")
pedimento = Pedimento.objects.create( pedimento = Pedimento.objects.create(
organizacion=organizacion, organizacion=organizacion,
contribuyente=importador, contribuyente=importador,
# pedimento=int(pedimento_num), # pedimento=int(pedimento_num),
pedimento=pedimento_num, pedimento=pedimento_num,
aduana=aduana, aduana=aduana,
# aduana=int(aduana), # aduana=int(aduana),
# patente=int(patente), # patente=int(patente),
patente=patente, patente=patente,
fecha_pago=fecha_pago, fecha_pago=fecha_pago,
pedimento_app=pedimento_app, pedimento_app=pedimento_app,
agente_aduanal=f"Agente {patente}", # Valor por defecto agente_aduanal=f"Agente {patente}", # Valor por defecto
clave_pedimento="A1" # Valor por defecto clave_pedimento="A1" # Valor por defecto
) )
print(f"✅ Pedimento creado exitosamente: ID {pedimento.id}, pedimento_app: {pedimento_app}") print(f"✅ Pedimento creado exitosamente: ID {pedimento.id}, pedimento_app: {pedimento_app}")
created_pedimentos.append({ created_pedimentos.append({
"id": str(pedimento.id), "id": str(pedimento.id),
"pedimento_app": pedimento_app, "pedimento_app": pedimento_app,
"contribuyente": importador.rfc, "contribuyente": importador.rfc,
"contribuyente_nombre": importador.nombre, "contribuyente_nombre": importador.nombre,
"archivo_original": archivo.name "archivo_original": archivo.name
}) })
except Exception as e: except Exception as e:
print(f"❌ Error al crear pedimento: {str(e)}") print(f"❌ Error al crear pedimento: {str(e)}")
failed_files.append({ failed_files.append({
"archivo_original": archivo.name, "archivo_original": archivo.name,
"error": f"Error al crear pedimento: {str(e)}" "error": f"Error al crear pedimento: {str(e)}"
}) })
continue continue
# Procesar documentos dentro del directorio # Procesar documentos dentro del directorio
print("Procesando documentos del directorio...") print("Procesando documentos del directorio...")
@@ -2248,6 +2372,7 @@ class ViewSetEDocument(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
serializer.save() serializer.save()
return 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(): 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 # Para usuarios normales, usar siempre su organización
if not hasattr(self.request.user, 'organizacion') or not self.request.user.organizacion: if not hasattr(self.request.user, 'organizacion') or not self.request.user.organizacion:
@@ -2355,6 +2480,15 @@ class ImportadorViewSet(viewsets.ModelViewSet, OrganizacionFiltradaMixin):
model = Importador model = Importador
def get_queryset(self): 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() return self.get_queryset_filtrado_por_organizacion()
def perform_create(self, serializer): def perform_create(self, serializer):
@@ -2889,7 +3023,7 @@ def extract_django_suffix(filename):
""" """
name_without_ext = os.path.splitext(filename)[0] 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: if match:
return match.group(1) return match.group(1)
return None return None
@@ -2903,7 +3037,7 @@ def get_clean_base_filename(filename):
django_suffix = extract_django_suffix(name_without_ext) django_suffix = extract_django_suffix(name_without_ext)
if django_suffix: if django_suffix:
base_name = name_without_ext[:-8] base_name = name_without_ext[:-9] # elimina _XXXXXXXX (underscore + 8 chars UUID)
else: else:
base_name = name_without_ext base_name = name_without_ext

View File

@@ -273,6 +273,9 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
if ruta: if ruta:
documento.archivo = ruta documento.archivo = ruta
documento.save() 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: else:
documento.delete() documento.delete()
raise ValidationError({"archivo": "Error al guardar el archivo"}) raise ValidationError({"archivo": "Error al guardar el archivo"})
@@ -1321,6 +1324,12 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
"codigo": "bulk_storage_limit_exceeded" "codigo": "bulk_storage_limit_exceeded"
}, status=status.HTTP_400_BAD_REQUEST) }, 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 # Procesar cada archivo
espacio_usado_temp = espacio_inicial espacio_usado_temp = espacio_inicial
@@ -1335,28 +1344,58 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
# Obtener extensión del archivo # Obtener extensión del archivo
extension = file.name.split('.')[-1].lower() if '.' in file.name else '' extension = file.name.split('.')[-1].lower() if '.' in file.name else ''
# Crear el documento # Detectar si ya existe un documento con el mismo nombre base + extensión.
document = Document.objects.create( # storage_service agrega un sufijo UUID de 8 chars al guardar, hay que ignorarlo.
organizacion=organizacion, new_name_base = re.sub(r'_[a-zA-Z0-9]{8}$', '', os.path.splitext(file.name)[0]).lower().strip('_')
pedimento_id=pedimento_id, existing_doc = None
document_type=document_type, for doc in existing_docs:
size=file.size, if doc.archivo:
extension=extension 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
ruta = storage_service.save_document( if existing_doc:
file=file, # Reemplazar archivo del documento existente
organizacion_id=organizacion.id, if existing_doc.archivo:
pedimento_app=pedimento.pedimento_app, storage_service.delete_file(existing_doc.archivo.name)
metadata={'source': 'bulk_upload'} ruta = storage_service.save_document(
) file=file,
organizacion_id=organizacion.id,
if ruta: pedimento_app=pedimento.pedimento_app,
document.archivo = ruta metadata={'source': 'bulk_upload_replace'}
document.save() )
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: else:
document.delete() # Crear nuevo documento
raise Exception(f"Error al guardar archivo: {file.name}") 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 # Actualizar espacio usado
espacio_usado_temp += file.size espacio_usado_temp += file.size

View File

@@ -135,6 +135,33 @@ class ExportDataStageView(APIView):
else: else:
return str(value) 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)'}) @swagger_auto_schema(request_body=ExportModelSerializer, responses={200: 'Archivo generado (Excel o CSV)'})
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
""" """
@@ -148,6 +175,27 @@ class ExportDataStageView(APIView):
else: else:
return self.handle_simple_export(request) 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): def handle_simple_export(self, request):
"""Maneja exportación simple de DataStage (un solo modelo)""" """Maneja exportación simple de DataStage (un solo modelo)"""
model_name = request.data.get('model') model_name = request.data.get('model')
@@ -159,6 +207,10 @@ class ExportDataStageView(APIView):
if not model_name or not fields: if not model_name or not fields:
return Response({'error': 'model and fields are required'}, status=status.HTTP_400_BAD_REQUEST) 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: try:
model = apps.get_model(module, model_name) model = apps.get_model(module, model_name)
filters = self.apply_global_filters_to_model(global_filters, model, request.user) filters = self.apply_global_filters_to_model(global_filters, model, request.user)
@@ -190,18 +242,16 @@ class ExportDataStageView(APIView):
if not models_data: if not models_data:
return Response({'error': 'models are required for multiple export'}, status=status.HTTP_400_BAD_REQUEST) 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) related_keys = self.get_related_keys_from_filters(global_filters, models_data, request.user)
if export_type == 'excel': 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) return self.export_datastage_multiple_partitioned_excel_agrupados(request, models_data, global_filters, related_keys)
else: else:
# Para CSV, podemos mantener la lógica actual o mejorarla return self.export_datastage_multiple_to_csv_combined(request, models_data, global_filters, related_keys)
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)
def estimate_total_records(self, models_data, global_filters, related_keys, user): def estimate_total_records(self, models_data, global_filters, related_keys, user):
"""Estima el total de registros para todos los modelos""" """Estima el total de registros para todos los modelos"""
@@ -282,290 +332,229 @@ class ExportDataStageView(APIView):
def export_datastage_multiple_partitioned_excel_agrupados(self, request, models_data, global_filters, related_keys): 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""" """Exporta múltiples modelos de DataStage agrupados en la misma hoja de Excel, con particionado por límite de registros"""
try: try:
zip_buffer = io.BytesIO()
# 🔥 PRECARGAR ORGANIZACIONES para mapeo rápido
from api.organization.models import Organizacion from api.organization.models import Organizacion
organizaciones = Organizacion.objects.all() org_mapping = {str(org.id): org.nombre for org in Organizacion.objects.all()}
org_mapping = {str(org.id): org.nombre for org in organizaciones}
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 for model_data in models_data:
all_models_data = {} # Ahora será una lista por clave model_name = model_data.get('model')
model_field_mappings = {} fields = model_data.get('fields', [])
for model_data in models_data: if not model_name or not fields:
model_name = model_data.get('model') continue
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')
normalized_fields = []
for f in fields:
try: try:
model = apps.get_model('datastage', model_name) key = f.strip() if isinstance(f, str) else f
filters = self.apply_related_filters(global_filters, model, related_keys, request.user) except Exception:
key = f
if filters: if isinstance(key, str) and key.lower() == 'organizacion':
queryset = model.objects.filter(**filters).values(*fields) if 'organizacion_id' not in normalized_fields:
else: normalized_fields.append('organizacion_id')
queryset = model.objects.none() else:
if key not in normalized_fields:
normalized_fields.append(key)
total_records = queryset.count() fields = normalized_fields
if total_records == 0: required_fields = ['seccion_aduanera', 'patente', 'pedimento']
continue for field in required_fields:
if field not in fields:
fields.append(field)
# Determinar campos de relación disponibles en este 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()]:
relation_fields = [] fields.append('organizacion_id')
for field_name in ['seccion_aduanera', 'patente', 'pedimento']:
if field_name in fields:
relation_fields.append(field_name)
if not relation_fields: try:
# Si no hay campos de relación, usar un identificador único model = apps.get_model('datastage', model_name)
relation_fields = ['datastage_id'] if 'datastage_id' in fields else [fields[0]] filters = self.apply_related_filters(global_filters, model, related_keys, request.user)
# Guardar mapeo de campos para este modelo if filters:
if model_name not in model_field_mappings: queryset = model.objects.filter(**filters).values(*fields)
model_field_mappings[model_name] = fields else:
queryset = model.objects.none()
# Procesar cada registro if queryset.count() == 0:
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 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:
continue continue
# Si no hay datos, retornar error relation_fields = [fn for fn in ['seccion_aduanera', 'patente', 'pedimento'] if fn in fields]
if not all_models_data: if not relation_fields:
return Response({'error': 'No se encontraron datos para exportar'}, status=status.HTTP_404_NOT_FOUND) relation_fields = ['datastage_id'] if 'datastage_id' in fields else [fields[0]]
# 2. Crear estructura de filas combinadas if model_name not in model_field_mappings:
# Ahora necesitamos expandir las filas cuando hay múltiples registros con la misma clave model_field_mappings[model_name] = fields
combined_rows = []
for key, data in all_models_data.items(): for record in queryset:
relation_fields = data['relation_fields'] key_parts = [str(record[rf]) for rf in relation_fields if rf in record and record[rf] is not None]
model_records = data['model_records'] if not key_parts:
import hashlib
key = hashlib.md5(str(sorted(record.items())).encode()).hexdigest()[:10]
else:
key = "_".join(key_parts)
# 🔥 NUEVO: Calcular cuántas filas necesitamos para esta clave processed_record = {}
# Encontrar el modelo con más registros para esta clave for field_name, value in record.items():
max_records_per_key = 1 if field_name == 'organizacion_id' and value:
for model_name, records in model_records.items(): org_id_str = str(value)
if len(records) > max_records_per_key: if org_id_str in org_mapping:
max_records_per_key = len(records) processed_value = org_mapping[org_id_str]
else:
# 🔗 CREAR UNA FILA POR CADA COMBINACIÓN try:
for i in range(max_records_per_key): org = Organizacion.objects.filter(id=value).first()
row_data = {} processed_value = org.nombre if org else org_id_str
org_mapping[org_id_str] = processed_value
# Campos de relación (mismos para todas las filas con esta clave) except Exception:
for rel_field, rel_value in relation_fields.items(): processed_value = org_id_str
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
else: else:
# Si no hay más registros para este modelo, poner campos vacíos processed_value = value
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] = ''
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 if field_name == 'organizacion_id':
all_fields_set = set() prefixed_field_name = prefixed_field_name.replace('organizacion_id', 'organizacion_nombre')
# Campos de relación primero processed_record[prefixed_field_name] = self.safe_excel_value(processed_value)
common_relation_fields = ['seccion_aduanera', 'patente', 'pedimento']
# Agregar todos los campos de todas las filas if key not in all_models_data:
for row in combined_rows: all_models_data[key] = {'relation_fields': {}, 'model_records': {}}
all_fields_set.update(row.keys())
# Ordenar campos: relación primero, luego alfabéticamente for rel_field in relation_fields:
all_fields = [] if rel_field in record:
for rel_field in common_relation_fields: all_models_data[key]['relation_fields'][rel_field] = record[rel_field]
if rel_field in all_fields_set:
all_fields.append(rel_field)
all_fields_set.remove(rel_field)
# 🔥 Mover organizacion_nombre cerca de los campos de relación if model_name not in all_models_data[key]['model_records']:
org_fields = [f for f in all_fields_set if 'organizacion' in f.lower()] all_models_data[key]['model_records'][model_name] = []
for org_field in sorted(org_fields):
all_fields.append(org_field)
all_fields_set.remove(org_field)
# Agregar el resto de campos ordenados alfabéticamente all_models_data[key]['model_records'][model_name].append(processed_record)
all_fields.extend(sorted(all_fields_set))
total_records = len(combined_rows) except LookupError:
continue
# 4. Manejar particionado # 2. Sin datos → Excel vacío (no JSON 404 que rompe la descarga en el frontend)
from django.core.paginator import Paginator if not all_models_data:
paginator = Paginator(combined_rows, self.MAX_RECORDS_PER_FILE) 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: for page_num in paginator.page_range:
page = paginator.page(page_num) page = paginator.page(page_num)
# Crear nuevo workbook para cada partición
current_wb = openpyxl.Workbook() current_wb = openpyxl.Workbook()
current_ws = current_wb.active _write_sheet(current_wb.active, f"Datastage_p{page_num}", page.object_list)
# 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
part_buffer = io.BytesIO() part_buffer = io.BytesIO()
current_wb.save(part_buffer) current_wb.save(part_buffer)
part_buffer.seek(0) part_buffer.seek(0)
zip_file.writestr(f"datastage_part{page_num}.xlsx", part_buffer.getvalue()) 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) zip_buffer.seek(0)
resp = HttpResponse(zip_buffer.read(), content_type='application/zip')
response = HttpResponse(zip_buffer.read(), content_type='application/zip') resp['Content-Disposition'] = 'attachment; filename="datastage_combinado.zip"'
response['Content-Disposition'] = 'attachment; filename="datastage_combinado.zip"' return resp
return response
except Exception as e: except Exception as e:
import traceback import traceback
error_details = traceback.format_exc() import logging
print(f"Error en exportación: {error_details}") 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) return Response({'error': f'Error en exportación combinada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@@ -782,10 +771,6 @@ class ExportDataStageView(APIView):
part_buffer.seek(0) part_buffer.seek(0)
zip_file.writestr(f"datastage_part{page_num}.xlsx", part_buffer.getvalue()) 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) zip_buffer.seek(0)
@@ -795,12 +780,11 @@ class ExportDataStageView(APIView):
except Exception as e: except Exception as e:
import traceback import traceback
error_details = traceback.format_exc() import logging
print(f"Error en exportación: {error_details}") 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) 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): 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""" """Exporta múltiples modelos de DataStage agrupados en la misma hoja de Excel, con particionado por límite de registros"""
try: try:
@@ -1009,8 +993,8 @@ class ExportDataStageView(APIView):
except Exception as e: except Exception as e:
import traceback import traceback
error_details = traceback.format_exc() import logging
print(f"Error en exportación: {error_details}") 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) return Response({'error': f'Error en exportación combinada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@@ -1126,8 +1110,6 @@ class ExportDataStageView(APIView):
part_buffer.seek(0) part_buffer.seek(0)
zip_file.writestr(f"datastage_combinado_part{page_num}.xlsx", part_buffer.getvalue()) 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) zip_buffer.seek(0)
@@ -1137,8 +1119,8 @@ class ExportDataStageView(APIView):
except Exception as e: except Exception as e:
import traceback import traceback
error_details = traceback.format_exc() import logging
print(f"Error en exportación: {error_details}") 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) 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): def export_datastage_multiple_partitioned_excel(self, request, models_data, global_filters, related_keys):
@@ -1265,6 +1247,144 @@ class ExportDataStageView(APIView):
except Exception as e: except Exception as e:
return Response({'error': f'Error en exportación particionada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) 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): 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""" """Exporta múltiples modelos de DataStage a múltiples archivos CSV en ZIP"""
zip_buffer = io.BytesIO() zip_buffer = io.BytesIO()
@@ -1472,8 +1592,13 @@ class ExportDataStageView(APIView):
def get_related_keys_from_filters(self, global_filters, models_data, user): 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 Construye el conjunto de (patente, pedimento, datastage_id) que servirá como
VERSIÓN SIMPLIFICADA - Usa la MISMA lógica que apply_global_filters_to_model 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 = { related_keys = {
'patentes': set(), 'patentes': set(),
@@ -1481,41 +1606,35 @@ class ExportDataStageView(APIView):
'datastage_ids': 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, '']): if not any(v for v in global_filters.values() if v not in [None, '']):
return {} 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 = [] all_records_with_filters = []
for model_data in models_data: for model_data in models_data:
model_name = model_data.get('model') model_name = model_data.get('model')
try: try:
model = apps.get_model('datastage', model_name) model = apps.get_model('datastage', model_name)
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
# ¡USAR LA MISMA FUNCIÓN QUE EN MODO SINGULAR!
filters = self.apply_global_filters_to_model(global_filters, model, user) filters = self.apply_global_filters_to_model(global_filters, model, user)
if not filters:
continue
if filters: records = model.objects.filter(**filters).values('patente', 'pedimento', 'datastage_id')
# EJECUTAR CONSULTA - IDÉNTICO A MODO SINGULAR all_records_with_filters.extend(list(records))
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))
except LookupError: except LookupError:
continue continue
@@ -1585,9 +1704,17 @@ class ExportDataStageView(APIView):
filters = {} filters = {}
model_fields = [f.name for f in model._meta.get_fields()] 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'): 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!) # 2. RFC (¡ESTO ES LO QUE FALTA!)
if 'rfc' in model_fields and global_filters.get('rfc'): if 'rfc' in model_fields and global_filters.get('rfc'):