feature/T2026-05-016-y-T2026-05-031 #28
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'):
|
||||||
|
|||||||
Reference in New Issue
Block a user