fix/T2025-09-007 corregir documentos duplicados

This commit is contained in:
Dulce
2026-05-18 11:55:46 -06:00
parent 8cc0b9f573
commit c2ae752932
4 changed files with 707 additions and 407 deletions

View File

@@ -135,6 +135,33 @@ class ExportDataStageView(APIView):
else:
return str(value)
def get(self, request, *args, **kwargs):
"""Retorna RFCs distintos de Registro501 para la organización indicada. El parámetro organizacion es obligatorio."""
try:
Registro501 = apps.get_model('datastage', 'Registro501')
if not request.user.is_superuser:
qs = Registro501.objects.filter(organizacion=request.user.organizacion)
else:
org_id = request.query_params.get('organizacion')
if not org_id:
return Response({'error': 'El parámetro organizacion es obligatorio'}, status=status.HTTP_400_BAD_REQUEST)
try:
qs = Registro501.objects.filter(organizacion_id=uuid.UUID(org_id))
except (ValueError, AttributeError):
return Response({'error': 'UUID de organización inválido'}, status=status.HTTP_400_BAD_REQUEST)
rfcs = (
qs.exclude(rfc__isnull=True)
.exclude(rfc='')
.values_list('rfc', flat=True)
.distinct()
.order_by('rfc')
)
return Response({'rfcs': list(rfcs)})
except LookupError:
return Response({'rfcs': []})
@swagger_auto_schema(request_body=ExportModelSerializer, responses={200: 'Archivo generado (Excel o CSV)'})
def post(self, request, *args, **kwargs):
"""
@@ -148,6 +175,27 @@ class ExportDataStageView(APIView):
else:
return self.handle_simple_export(request)
def _resolve_org_filter(self, global_filters, user):
"""
Devuelve los global_filters asegurando que siempre haya una organización.
- Superuser sin org → error (no mezclar tenants).
- No-superuser sin org → se inyecta la org del usuario.
Retorna (filters_dict, error_response_or_None).
"""
org_value = (global_filters or {}).get('organizacion', '')
if not org_value:
if user.is_superuser:
return None, Response(
{'error': 'El parámetro organizacion es obligatorio'},
status=status.HTTP_400_BAD_REQUEST
)
# No-superuser: inyectar su propia org
if hasattr(user, 'organizacion') and user.organizacion:
filters = dict(global_filters or {})
filters['organizacion'] = str(user.organizacion.id)
return filters, None
return dict(global_filters or {}), None
def handle_simple_export(self, request):
"""Maneja exportación simple de DataStage (un solo modelo)"""
model_name = request.data.get('model')
@@ -159,6 +207,10 @@ class ExportDataStageView(APIView):
if not model_name or not fields:
return Response({'error': 'model and fields are required'}, status=status.HTTP_400_BAD_REQUEST)
global_filters, err = self._resolve_org_filter(global_filters, request.user)
if err:
return err
try:
model = apps.get_model(module, model_name)
filters = self.apply_global_filters_to_model(global_filters, model, request.user)
@@ -190,18 +242,16 @@ class ExportDataStageView(APIView):
if not models_data:
return Response({'error': 'models are required for multiple export'}, status=status.HTTP_400_BAD_REQUEST)
global_filters, err = self._resolve_org_filter(global_filters, request.user)
if err:
return err
related_keys = self.get_related_keys_from_filters(global_filters, models_data, request.user)
if export_type == 'excel':
# Siempre usar el método particionado inteligente para Excel
return self.export_datastage_multiple_partitioned_excel_agrupados(request, models_data, global_filters, related_keys)
else:
# Para CSV, podemos mantener la lógica actual o mejorarla
total_estimated_records = self.estimate_total_records(models_data, global_filters, related_keys, request.user)
if total_estimated_records > self.MAX_RECORDS_PER_FILE:
return self.export_datastage_multiple_partitioned_csv(request, models_data, global_filters, related_keys)
else:
return self.export_datastage_multiple_to_csv(request, models_data, global_filters, related_keys)
return self.export_datastage_multiple_to_csv_combined(request, models_data, global_filters, related_keys)
def estimate_total_records(self, models_data, global_filters, related_keys, user):
"""Estima el total de registros para todos los modelos"""
@@ -282,292 +332,231 @@ class ExportDataStageView(APIView):
def export_datastage_multiple_partitioned_excel_agrupados(self, request, models_data, global_filters, related_keys):
"""Exporta múltiples modelos de DataStage agrupados en la misma hoja de Excel, con particionado por límite de registros"""
try:
zip_buffer = io.BytesIO()
# 🔥 PRECARGAR ORGANIZACIONES para mapeo rápido
from api.organization.models import Organizacion
organizaciones = Organizacion.objects.all()
org_mapping = {str(org.id): org.nombre for org in organizaciones}
org_mapping = {str(org.id): org.nombre for org in Organizacion.objects.all()}
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
# 1. Recopilar todos los datos FUERA del contexto ZIP
all_models_data = {}
model_field_mappings = {}
# 1. Recopilar todos los datos de cada modelo
all_models_data = {} # Ahora será una lista por clave
model_field_mappings = {}
for model_data in models_data:
model_name = model_data.get('model')
fields = model_data.get('fields', [])
for model_data in models_data:
model_name = model_data.get('model')
fields = model_data.get('fields', [])
if not model_name or not fields:
continue
# Normalizar nombres de campo entrantes: si se pasó "Organizacion"
# (cualquier capitalización), usar el campo real de la BD `organizacion_id`.
normalized_fields = []
for f in fields:
try:
key = f.strip() if isinstance(f, str) else f
except Exception:
key = f
if isinstance(key, str) and key.lower() == 'organizacion':
if 'organizacion_id' not in normalized_fields:
normalized_fields.append('organizacion_id')
else:
if key not in normalized_fields:
normalized_fields.append(key)
fields = normalized_fields
# Asegurar que tenemos los campos de relación
required_fields = ['seccion_aduanera', 'patente', 'pedimento']
for field in required_fields:
if field not in fields:
fields.append(field)
# 🔥 Añadir organizacion_id a los campos si no está y existe en el modelo
if 'organizacion_id' not in fields and 'organizacion_id' in [f.name for f in apps.get_model('datastage', model_name)._meta.get_fields()]:
fields.append('organizacion_id')
if not model_name or not fields:
continue
normalized_fields = []
for f in fields:
try:
model = apps.get_model('datastage', model_name)
filters = self.apply_related_filters(global_filters, model, related_keys, request.user)
key = f.strip() if isinstance(f, str) else f
except Exception:
key = f
if filters:
queryset = model.objects.filter(**filters).values(*fields)
else:
queryset = model.objects.none()
if isinstance(key, str) and key.lower() == 'organizacion':
if 'organizacion_id' not in normalized_fields:
normalized_fields.append('organizacion_id')
else:
if key not in normalized_fields:
normalized_fields.append(key)
total_records = queryset.count()
fields = normalized_fields
if total_records == 0:
continue
# Determinar campos de relación disponibles en este modelo
relation_fields = []
for field_name in ['seccion_aduanera', 'patente', 'pedimento']:
if field_name in fields:
relation_fields.append(field_name)
required_fields = ['seccion_aduanera', 'patente', 'pedimento']
for field in required_fields:
if field not in fields:
fields.append(field)
if not relation_fields:
# Si no hay campos de relación, usar un identificador único
relation_fields = ['datastage_id'] if 'datastage_id' in fields else [fields[0]]
if 'organizacion_id' not in fields and 'organizacion_id' in [f.name for f in apps.get_model('datastage', model_name)._meta.get_fields()]:
fields.append('organizacion_id')
# Guardar mapeo de campos para este modelo
if model_name not in model_field_mappings:
model_field_mappings[model_name] = fields
try:
model = apps.get_model('datastage', model_name)
filters = self.apply_related_filters(global_filters, model, related_keys, request.user)
# Procesar cada registro
for record in queryset:
# Crear clave de relación
key_parts = []
for rel_field in relation_fields:
if rel_field in record and record[rel_field] is not None:
key_parts.append(str(record[rel_field]))
if filters:
queryset = model.objects.filter(**filters).values(*fields)
else:
queryset = model.objects.none()
if not key_parts:
# Si no hay campos de relación, usar un hash del registro
import hashlib
record_str = str(sorted(record.items()))
key = hashlib.md5(record_str.encode()).hexdigest()[:10]
else:
key = "_".join(key_parts)
# 🔥 PROCESAR CAMPO organizacion_id para convertirlo a nombre
processed_record = {}
for field_name, value in record.items():
# Convertir organizacion_id a nombre
if field_name == 'organizacion_id' and value:
org_id_str = str(value)
# Usar el nombre de la organización si está en el mapeo
if org_id_str in org_mapping:
processed_value = org_mapping[org_id_str]
else:
# Si no se encuentra, intentar obtener de la base de datos
try:
org = Organizacion.objects.filter(id=value).first()
processed_value = org.nombre if org else str(value)
# Actualizar mapeo para futuras referencias
org_mapping[org_id_str] = processed_value
except:
processed_value = str(value)
else:
processed_value = value
# Agregar prefijo del modelo a los campos para evitar colisiones
if field_name in relation_fields:
prefixed_field_name = field_name
else:
prefixed_field_name = f"{model_name}_{field_name}"
# 🔥 RENOMBRAR organizacion_id a organizacion_nombre
if field_name == 'organizacion_id':
prefixed_field_name = prefixed_field_name.replace('organizacion_id', 'organizacion_nombre')
processed_record[prefixed_field_name] = self.safe_excel_value(processed_value)
# 🔥 CORRECIÓN: Ahora almacenamos una LISTA de registros por clave
if key not in all_models_data:
all_models_data[key] = {
'relation_fields': {}, # Campos de relación compartidos
'model_records': {} # Diccionario de listas por modelo
}
# Guardar campos de relación (solo una vez, ya que son los mismos)
for rel_field in relation_fields:
if rel_field in record:
all_models_data[key]['relation_fields'][rel_field] = record[rel_field]
# 🔥 GUARDAR COMO LISTA: Crear lista si no existe
if model_name not in all_models_data[key]['model_records']:
all_models_data[key]['model_records'][model_name] = []
# Agregar este registro a la lista del modelo
all_models_data[key]['model_records'][model_name].append(processed_record)
except LookupError:
if queryset.count() == 0:
continue
# Si no hay datos, retornar error
if not all_models_data:
return Response({'error': 'No se encontraron datos para exportar'}, status=status.HTTP_404_NOT_FOUND)
# 2. Crear estructura de filas combinadas
# Ahora necesitamos expandir las filas cuando hay múltiples registros con la misma clave
combined_rows = []
relation_fields = [fn for fn in ['seccion_aduanera', 'patente', 'pedimento'] if fn in fields]
if not relation_fields:
relation_fields = ['datastage_id'] if 'datastage_id' in fields else [fields[0]]
for key, data in all_models_data.items():
relation_fields = data['relation_fields']
model_records = data['model_records']
if model_name not in model_field_mappings:
model_field_mappings[model_name] = fields
# 🔥 NUEVO: Calcular cuántas filas necesitamos para esta clave
# Encontrar el modelo con más registros para esta clave
max_records_per_key = 1
for model_name, records in model_records.items():
if len(records) > max_records_per_key:
max_records_per_key = len(records)
for record in queryset:
key_parts = [str(record[rf]) for rf in relation_fields if rf in record and record[rf] is not None]
if not key_parts:
import hashlib
key = hashlib.md5(str(sorted(record.items())).encode()).hexdigest()[:10]
else:
key = "_".join(key_parts)
# 🔗 CREAR UNA FILA POR CADA COMBINACIÓN
for i in range(max_records_per_key):
row_data = {}
# Campos de relación (mismos para todas las filas con esta clave)
for rel_field, rel_value in relation_fields.items():
row_data[rel_field] = self.safe_excel_value(rel_value)
# Datos de cada modelo
for model_name, records in model_records.items():
# Si hay un registro en esta posición i
if i < len(records):
record = records[i]
for field_name, value in record.items():
row_data[field_name] = value
processed_record = {}
for field_name, value in record.items():
if field_name == 'organizacion_id' and value:
org_id_str = str(value)
if org_id_str in org_mapping:
processed_value = org_mapping[org_id_str]
else:
try:
org = Organizacion.objects.filter(id=value).first()
processed_value = org.nombre if org else org_id_str
org_mapping[org_id_str] = processed_value
except Exception:
processed_value = org_id_str
else:
# Si no hay más registros para este modelo, poner campos vacíos
for field_name in model_field_mappings.get(model_name, []):
if field_name in ['seccion_aduanera', 'patente', 'pedimento', 'organizacion_id']:
# Los campos de relación ya están llenados o transformados
continue
prefixed_field_name = f"{model_name}_{field_name}"
# 🔥 RENOMBRAR organizacion_id a organizacion_nombre
if field_name == 'organizacion_id':
prefixed_field_name = prefixed_field_name.replace('organizacion_id', 'organizacion_nombre')
row_data[prefixed_field_name] = ''
processed_value = value
combined_rows.append(row_data)
if field_name in relation_fields:
prefixed_field_name = field_name
else:
prefixed_field_name = f"{model_name}_{field_name}"
# 3. Determinar todos los campos únicos para los encabezados
all_fields_set = set()
if field_name == 'organizacion_id':
prefixed_field_name = prefixed_field_name.replace('organizacion_id', 'organizacion_nombre')
# Campos de relación primero
common_relation_fields = ['seccion_aduanera', 'patente', 'pedimento']
processed_record[prefixed_field_name] = self.safe_excel_value(processed_value)
# Agregar todos los campos de todas las filas
for row in combined_rows:
all_fields_set.update(row.keys())
if key not in all_models_data:
all_models_data[key] = {'relation_fields': {}, 'model_records': {}}
# Ordenar campos: relación primero, luego alfabéticamente
all_fields = []
for rel_field in common_relation_fields:
if rel_field in all_fields_set:
all_fields.append(rel_field)
all_fields_set.remove(rel_field)
for rel_field in relation_fields:
if rel_field in record:
all_models_data[key]['relation_fields'][rel_field] = record[rel_field]
# 🔥 Mover organizacion_nombre cerca de los campos de relación
org_fields = [f for f in all_fields_set if 'organizacion' in f.lower()]
for org_field in sorted(org_fields):
all_fields.append(org_field)
all_fields_set.remove(org_field)
if model_name not in all_models_data[key]['model_records']:
all_models_data[key]['model_records'][model_name] = []
# Agregar el resto de campos ordenados alfabéticamente
all_fields.extend(sorted(all_fields_set))
all_models_data[key]['model_records'][model_name].append(processed_record)
total_records = len(combined_rows)
except LookupError:
continue
# 4. Manejar particionado
from django.core.paginator import Paginator
paginator = Paginator(combined_rows, self.MAX_RECORDS_PER_FILE)
# 2. Sin datos → Excel vacío (no JSON 404 que rompe la descarga en el frontend)
if not all_models_data:
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Sin datos"
ws.append(["No se encontraron datos para los filtros especificados"])
output = io.BytesIO()
wb.save(output)
output.seek(0)
resp = HttpResponse(
output.read(),
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
resp['Content-Disposition'] = 'attachment; filename="datastage_sin_datos.xlsx"'
return resp
# 3. Construir filas combinadas — repetir el último registro en lugar de dejar vacíos
combined_rows = []
for key, data in all_models_data.items():
relation_fields_data = data['relation_fields']
model_records = data['model_records']
max_records_per_key = max((len(recs) for recs in model_records.values()), default=1)
for i in range(max_records_per_key):
row_data = {}
for rel_field, rel_value in relation_fields_data.items():
row_data[rel_field] = self.safe_excel_value(rel_value)
for model_name, records in model_records.items():
# Usar posición i o el último registro disponible
record = records[i] if i < len(records) else records[-1]
for field_name, value in record.items():
row_data[field_name] = value
combined_rows.append(row_data)
# 4. Encabezados ordenados
all_fields_set = set()
for row in combined_rows:
all_fields_set.update(row.keys())
all_fields = []
for rel_field in ['seccion_aduanera', 'patente', 'pedimento']:
if rel_field in all_fields_set:
all_fields.append(rel_field)
all_fields_set.discard(rel_field)
org_fields = sorted(f for f in all_fields_set if 'organizacion' in f.lower())
for org_field in org_fields:
all_fields.append(org_field)
all_fields_set.discard(org_field)
all_fields.extend(sorted(all_fields_set))
# 5. Filas de título y fecha de generación
now_str = datetime.datetime.now().strftime('%d/%m/%Y %H:%M:%S')
title_row = ["Reporte Datastage"]
date_row = [f"Generado: {now_str}"]
def _write_sheet(ws, sheet_name, page_rows):
ws.title = sheet_name[:31]
ws.append(title_row)
ws.append(date_row)
ws.append([])
ws.append(all_fields)
for row_data in page_rows:
ws.append([row_data.get(field, '') for field in all_fields])
for column in ws.columns:
max_length = 0
col_letter = column[0].column_letter
for cell in column:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except Exception:
pass
ws.column_dimensions[col_letter].width = min(max_length + 2, 50)
# 6. Excel directo si cabe en un archivo; ZIP solo si se necesita particionar
from django.core.paginator import Paginator
paginator = Paginator(combined_rows, self.MAX_RECORDS_PER_FILE)
if paginator.num_pages == 1:
wb = openpyxl.Workbook()
_write_sheet(wb.active, "Datastage", paginator.page(1).object_list)
output = io.BytesIO()
wb.save(output)
output.seek(0)
resp = HttpResponse(
output.read(),
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
resp['Content-Disposition'] = 'attachment; filename="datastage_reporte.xlsx"'
return resp
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
for page_num in paginator.page_range:
page = paginator.page(page_num)
# Crear nuevo workbook para cada partición
current_wb = openpyxl.Workbook()
current_ws = current_wb.active
# Nombre de hoja limitado a 31 caracteres
sheet_name = f"Datastage_p{page_num}"
if len(sheet_name) > 31:
sheet_name = sheet_name[:31]
current_ws.title = sheet_name
# Escribir encabezados
current_ws.append(all_fields)
# Escribir datos de esta página
for row_data in page.object_list:
row_values = [row_data.get(field, '') for field in all_fields]
current_ws.append(row_values)
# Autoajustar anchos de columna
for column in current_ws.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except:
pass
adjusted_width = min(max_length + 2, 50)
current_ws.column_dimensions[column_letter].width = adjusted_width
# Guardar archivo en ZIP
_write_sheet(current_wb.active, f"Datastage_p{page_num}", page.object_list)
part_buffer = io.BytesIO()
current_wb.save(part_buffer)
part_buffer.seek(0)
zip_file.writestr(f"datastage_part{page_num}.xlsx", part_buffer.getvalue())
# Información de depuración
print(f"Creada partición {page_num} con {len(page.object_list)} registros combinados")
print(f"Total de claves únicas: {len(all_models_data)}")
print(f"Total de filas expandidas: {total_records}")
zip_buffer.seek(0)
response = HttpResponse(zip_buffer.read(), content_type='application/zip')
response['Content-Disposition'] = 'attachment; filename="datastage_combinado.zip"'
return response
resp = HttpResponse(zip_buffer.read(), content_type='application/zip')
resp['Content-Disposition'] = 'attachment; filename="datastage_combinado.zip"'
return resp
except Exception as e:
import traceback
error_details = traceback.format_exc()
print(f"Error en exportación: {error_details}")
import logging
logging.getLogger(__name__).error("Error en exportación combinada: %s", traceback.format_exc())
return Response({'error': f'Error en exportación combinada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
def export_datastage_multiple_partitioned_excel_test_3(self, request, models_data, global_filters, related_keys):
"""Exporta múltiples modelos de DataStage agrupados en la misma hoja de Excel, con particionado por límite de registros"""
@@ -782,10 +771,6 @@ class ExportDataStageView(APIView):
part_buffer.seek(0)
zip_file.writestr(f"datastage_part{page_num}.xlsx", part_buffer.getvalue())
# Información de depuración
print(f"Creada partición {page_num} con {len(page.object_list)} registros combinados")
print(f"Total de claves únicas: {len(all_models_data)}")
print(f"Total de filas expandidas: {total_records}")
zip_buffer.seek(0)
@@ -795,12 +780,11 @@ class ExportDataStageView(APIView):
except Exception as e:
import traceback
error_details = traceback.format_exc()
print(f"Error en exportación: {error_details}")
import logging
logging.getLogger(__name__).error("Error en exportación combinada: %s", traceback.format_exc())
return Response({'error': f'Error en exportación combinada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
def export_datastage_multiple_partitioned_excel_test_2(self, request, models_data, global_filters, related_keys):
"""Exporta múltiples modelos de DataStage agrupados en la misma hoja de Excel, con particionado por límite de registros"""
try:
@@ -1009,9 +993,9 @@ class ExportDataStageView(APIView):
except Exception as e:
import traceback
error_details = traceback.format_exc()
print(f"Error en exportación: {error_details}")
return Response({'error': f'Error en exportación combinada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
import logging
logging.getLogger(__name__).error("Error en exportación combinada: %s", traceback.format_exc())
return Response({'error': f'Error en exportación combinada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
def export_datastage_multiple_partitioned_excel_test(self, request, models_data, global_filters, related_keys):
@@ -1126,8 +1110,6 @@ class ExportDataStageView(APIView):
part_buffer.seek(0)
zip_file.writestr(f"datastage_combinado_part{page_num}.xlsx", part_buffer.getvalue())
# Información de depuración (opcional)
print(f"Creada partición {page_num} con {len(page.object_list)} registros")
zip_buffer.seek(0)
@@ -1137,9 +1119,9 @@ class ExportDataStageView(APIView):
except Exception as e:
import traceback
error_details = traceback.format_exc()
print(f"Error en exportación: {error_details}")
return Response({'error': f'Error en exportación combinada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
import logging
logging.getLogger(__name__).error("Error en exportación combinada: %s", traceback.format_exc())
return Response({'error': f'Error en exportación combinada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
def export_datastage_multiple_partitioned_excel(self, request, models_data, global_filters, related_keys):
"""Exporta múltiples modelos de DataStage a múltiples archivos Excel particionados inteligentemente"""
@@ -1265,6 +1247,144 @@ class ExportDataStageView(APIView):
except Exception as e:
return Response({'error': f'Error en exportación particionada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
def export_datastage_multiple_to_csv_combined(self, request, models_data, global_filters, related_keys):
"""Exporta múltiples modelos combinados en un único CSV plano (misma lógica de agrupación que el Excel)."""
import hashlib
import logging
import traceback
logger = logging.getLogger(__name__)
try:
from api.organization.models import Organizacion
org_mapping = {str(org.id): org.nombre for org in Organizacion.objects.all()}
all_models_data = {}
model_field_mappings = {}
for model_data in models_data:
model_name = model_data.get('model')
fields = model_data.get('fields', [])
if not model_name or not fields:
continue
normalized_fields = []
for f in fields:
key = f.strip() if isinstance(f, str) else f
if isinstance(key, str) and key.lower() == 'organizacion':
if 'organizacion_id' not in normalized_fields:
normalized_fields.append('organizacion_id')
else:
if key not in normalized_fields:
normalized_fields.append(key)
fields = normalized_fields
for req_field in ['seccion_aduanera', 'patente', 'pedimento']:
if req_field not in fields:
fields.append(req_field)
try:
model = apps.get_model('datastage', model_name)
model_field_names = [f.name for f in model._meta.get_fields() if hasattr(f, 'name')]
if 'organizacion_id' not in fields and 'organizacion_id' in model_field_names:
fields.append('organizacion_id')
filters = self.apply_related_filters(global_filters, model, related_keys, request.user)
queryset = model.objects.filter(**filters).values(*fields) if filters else model.objects.none()
if queryset.count() == 0:
continue
relation_fields = [fn for fn in ['seccion_aduanera', 'patente', 'pedimento'] if fn in fields]
if not relation_fields:
relation_fields = ['datastage_id'] if 'datastage_id' in fields else [fields[0]]
if model_name not in model_field_mappings:
model_field_mappings[model_name] = fields
for record in queryset:
key_parts = [str(record[rf]) for rf in relation_fields if rf in record and record[rf] is not None]
key = "_".join(key_parts) if key_parts else hashlib.md5(str(sorted(record.items())).encode()).hexdigest()[:10]
processed_record = {}
for field_name, value in record.items():
if field_name == 'organizacion_id' and value:
org_id_str = str(value)
processed_value = org_mapping.get(org_id_str, org_id_str)
else:
processed_value = value
if field_name in relation_fields:
prefixed = field_name
else:
prefixed = f"{model_name}_{field_name}"
if field_name == 'organizacion_id':
prefixed = prefixed.replace('organizacion_id', 'organizacion_nombre')
processed_record[prefixed] = self.safe_excel_value(processed_value)
if key not in all_models_data:
all_models_data[key] = {'relation_fields': {}, 'model_records': {}}
for rel_field in relation_fields:
if rel_field in record:
all_models_data[key]['relation_fields'][rel_field] = record[rel_field]
if model_name not in all_models_data[key]['model_records']:
all_models_data[key]['model_records'][model_name] = []
all_models_data[key]['model_records'][model_name].append(processed_record)
except LookupError:
continue
# Sin datos → CSV con mensaje, no error HTTP
if not all_models_data:
buf = io.StringIO()
csv.writer(buf).writerow(['No se encontraron datos para los filtros especificados'])
resp = HttpResponse(buf.getvalue(), content_type='text/csv; charset=utf-8')
resp['Content-Disposition'] = 'attachment; filename="datastage_sin_datos.csv"'
return resp
# Construir filas planas
combined_rows = []
for key, data in all_models_data.items():
relation_fields_data = data['relation_fields']
model_records = data['model_records']
max_records = max((len(recs) for recs in model_records.values()), default=1)
for i in range(max_records):
row_data = {}
for rel_field, rel_value in relation_fields_data.items():
row_data[rel_field] = self.safe_excel_value(rel_value)
for mn, records in model_records.items():
record = records[i] if i < len(records) else records[-1]
for field_name, value in record.items():
row_data[field_name] = value
combined_rows.append(row_data)
# Encabezados: campos de relación primero, luego org, luego el resto
all_fields_set = set()
for row in combined_rows:
all_fields_set.update(row.keys())
all_fields = []
for rel_field in ['seccion_aduanera', 'patente', 'pedimento']:
if rel_field in all_fields_set:
all_fields.append(rel_field)
all_fields_set.discard(rel_field)
org_fields = sorted(f for f in all_fields_set if 'organizacion' in f.lower())
for org_field in org_fields:
all_fields.append(org_field)
all_fields_set.discard(org_field)
all_fields.extend(sorted(all_fields_set))
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(all_fields)
for row_data in combined_rows:
writer.writerow([row_data.get(field, '') for field in all_fields])
resp = HttpResponse(buf.getvalue(), content_type='text/csv; charset=utf-8')
resp['Content-Disposition'] = 'attachment; filename="datastage_reporte.csv"'
return resp
except Exception as e:
logger.error("Error en exportación CSV combinada: %s", traceback.format_exc())
return Response({'error': f'Error en exportación CSV combinada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
def export_datastage_multiple_to_csv(self, request, models_data, global_filters, related_keys):
"""Exporta múltiples modelos de DataStage a múltiples archivos CSV en ZIP"""
zip_buffer = io.BytesIO()
@@ -1472,57 +1592,56 @@ class ExportDataStageView(APIView):
def get_related_keys_from_filters(self, global_filters, models_data, user):
"""
Obtiene patentes, pedimentos y datastages que cumplen EXACTAMENTE con TODOS los filtros globales
VERSIÓN SIMPLIFICADA - Usa la MISMA lógica que apply_global_filters_to_model
Construye el conjunto de (patente, pedimento, datastage_id) que servirá como
llave de cruce entre modelos.
Regla clave: si el filtro RFC está activo, solo los modelos que tienen el campo
'rfc' pueden contribuir a related_keys. Los modelos sin 'rfc' (ej. 505, 506)
no se usan como semilla — solo se filtrarán más tarde usando las claves ya
construidas, evitando que contaminen el resultado con pedimentos de otros RFC.
"""
related_keys = {
'patentes': set(),
'pedimentos': set(),
'datastage_ids': set()
}
# Si no hay filtros, retornar vacío
# Sin filtros significativos → sin cruce
if not any(v for v in global_filters.values() if v not in [None, '']):
return {}
rfc_filter_active = bool(global_filters.get('rfc'))
date_filter_active = bool(global_filters.get('fecha_pago_desde') or global_filters.get('fecha_pago_hasta'))
all_records_with_filters = []
for model_data in models_data:
model_name = model_data.get('model')
try:
model = apps.get_model('datastage', model_name)
# ¡USAR LA MISMA FUNCIÓN QUE EN MODO SINGULAR!
model_field_names = {f.name for f in model._meta.get_fields() if hasattr(f, 'name')}
# Un modelo puede ser semilla de related_keys SOLO si tiene campos
# para aplicar TODOS los filtros activos. Un modelo sin 'rfc' no puede
# ser semilla cuando hay filtro de RFC (contaminaría con pedimentos de
# otros RFCs). Igual para fecha_pago_real cuando hay filtro de fechas.
if rfc_filter_active and 'rfc' not in model_field_names:
continue
if date_filter_active and 'fecha_pago_real' not in model_field_names:
continue
filters = self.apply_global_filters_to_model(global_filters, model, user)
if filters:
# EJECUTAR CONSULTA - IDÉNTICO A MODO SINGULAR
queryset = model.objects.filter(**filters)
total = queryset.count()
# VERIFICACIÓN ESPECIAL PARA RFC
if 'rfc' in filters:
rfc_value = filters['rfc']
# Doble verificación: contar registros con ese RFC exacto
rfc_exact_count = queryset.filter(rfc=rfc_value).count()
if rfc_exact_count != total:
try:
other_rfcs = queryset.exclude(rfc=rfc_value).values_list('rfc', flat=True).distinct()[:5]
except:
pass
# Obtener registros
records = queryset.values('patente', 'pedimento', 'datastage_id')
all_records_with_filters.extend(list(records))
if not filters:
continue
records = model.objects.filter(**filters).values('patente', 'pedimento', 'datastage_id')
all_records_with_filters.extend(list(records))
except LookupError:
continue
if not all_records_with_filters:
return {'patentes': set(), 'pedimentos': set(), 'datastage_ids': set()}
for record in all_records_with_filters:
if record.get('patente'):
related_keys['patentes'].add(record['patente'])
@@ -1530,7 +1649,7 @@ class ExportDataStageView(APIView):
related_keys['pedimentos'].add(record['pedimento'])
if record.get('datastage_id'):
related_keys['datastage_ids'].add(record['datastage_id'])
return {k: list(v) for k, v in related_keys.items() if v}
def apply_global_filters_to_model(self, global_filters, model, user):
@@ -1585,9 +1704,17 @@ class ExportDataStageView(APIView):
filters = {}
model_fields = [f.name for f in model._meta.get_fields()]
# 1. Organización
# 1. Organización — convertir a UUID igual que apply_global_filters_to_model
if 'organizacion' in model_fields and global_filters.get('organizacion'):
filters['organizacion'] = global_filters['organizacion']
org_value = global_filters['organizacion']
try:
field = model._meta.get_field('organizacion')
if hasattr(field, 'related_model'):
filters['organizacion_id'] = uuid.UUID(org_value)
else:
filters['organizacion'] = org_value
except Exception:
filters['organizacion_id'] = org_value
# 2. RFC (¡ESTO ES LO QUE FALTA!)
if 'rfc' in model_fields and global_filters.get('rfc'):