17 Commits

Author SHA1 Message Date
Dulce
4b2f3192d0 mitigar la duplicidad de archivos a la hora de hacer bulk de documentos 2025-12-29 07:22:30 -07:00
22f1bc5390 Update api/reports/tasks/report_document.py 2025-12-16 16:01:35 +00:00
fdbc7ba4db Merge pull request 'correccion-reportes' (#9) from correccion-reportes into main
Reviewed-on: #9
2025-12-16 16:00:57 +00:00
fb843954b6 Merge pull request 'FIX2025-10-021' (#8) from FIX2025-10-021 into main
Reviewed-on: #8
2025-12-16 15:59:18 +00:00
1cb2830d71 fix: se quitan los print del endpoint bulk-create-pedimento_desk 2025-12-16 08:30:49 -07:00
Dulce
a112d746f6 eliminar loggers 2025-12-16 08:22:59 -07:00
942847680a Fix: Se crea nuevo endpoint para subir documentos a expediente electronico. 2025-12-16 07:29:54 -07:00
Dulce
dad4fa2191 reportes con datos de fechas y reportes diferidos corregidos 2025-12-15 13:32:53 -07:00
421aa0c0da Merge pull request 'T2025-10-152' (#7) from T2025-10-152 into main
Reviewed-on: #7
2025-12-12 22:02:27 +00:00
97ac547a4b fix: Se modifica Dockerfile.prod para que instale unrar desde pagina oficial. 2025-12-10 11:44:13 -07:00
ed63a4854c fix: Se ajusta carga de expediente en RAR y se agega validacion para evitar duplicar el pedimento independientemente de la organizacion de carga. 2025-12-10 11:30:58 -07:00
202b053698 fix/reubicacion del documentos del detalle de pedimentos 2025-12-05 08:16:16 -07:00
48de6f8658 Merge pull request 'reportes' (#6) from reportes into main
Reviewed-on: #6
2025-11-25 22:19:31 +00:00
a75e9d1ebc Merge pull request 'Se habilita funcionalidad para crear pediementos con sus documentos apartir de una carpeta, zip o rar' (#5) from T2025-09-007 into main
Reviewed-on: #5
2025-11-25 22:01:36 +00:00
5042781fdd Merge pull request 'Se habilita opcion de descarga de pedimentos individuales, masivos, por filtro.' (#4) from T2025-09-020 into main
Reviewed-on: #4
2025-11-25 21:58:36 +00:00
77f9fe4389 Se habilita funcionalidad para crear pediementos con sus documentos apartir de una carpeta, zip o rar 2025-11-24 08:51:42 -07:00
72c0d70a71 Se habilita opcion de descarga de pedimentos individuales, masivos, por filtro. 2025-11-24 08:25:59 -07:00
10 changed files with 1902 additions and 149 deletions

2
.gitignore vendored
View File

@@ -178,4 +178,4 @@ cython_debug/
#.idea/
# End of https://www.toptal.com/developers/gitignore/api/django
*.bak

View File

@@ -3,12 +3,17 @@ FROM python:3.11-slim
WORKDIR /app
# Instalar dependencias del sistema necesarias
RUN apt-get update && apt-get install -y --no-install-recommends wget && \
wget https://www.rarlab.com/rar/rarlinux-x64-621.tar.gz && \
tar -xzvf rarlinux*.tar.gz && \
cp rar/unrar /usr/bin/unrar && \
rm -rf rarlinux*.tar.gz rar
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install flower
COPY . .

View File

@@ -3,8 +3,16 @@ FROM python:3.11-slim
WORKDIR /app
# Instalar dependencias del sistema
RUN apt-get update && apt-get install -y \
# RUN apt-get update && apt-get install -y \
# supervisor \
# && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y --no-install-recommends \
supervisor \
wget \
&& wget https://www.rarlab.com/rar/rarlinux-x64-621.tar.gz \
&& tar -xzvf rarlinux*.tar.gz \
&& cp rar/unrar /usr/bin/unrar \
&& rm -rf rarlinux*.tar.gz rar \
&& rm -rf /var/lib/apt/lists/*
# Copiar e instalar dependencias de Python

View File

@@ -9,6 +9,7 @@ from api.customs.models import (
Partida
)
from django.db import models
from django.db.models import Q
from api.record.models import Document # Asegúrate de importar el modelo Documento
from api.record.serializers import DocumentSerializer
from api.vucem.serializers import VucemSerializer
@@ -43,6 +44,59 @@ class PedimentoSerializer(serializers.ModelSerializer):
return rep
class PartidaSerializer(serializers.ModelSerializer):
documentos = serializers.SerializerMethodField()
def get_documentos(self, obj):
"""
Busca documentos en la tabla `document` que coincidan EXACTAMENTE con:
'documents/vu_PT_{pedimentoApp}_{numero}' al inicio del nombre del archivo.
"""
if not obj or not getattr(obj, 'pedimento', None):
return []
if not obj or not getattr(obj, 'numero_partida', None):
return []
try:
pedimentoApp = str(obj.pedimento.pedimento_app).strip()
numero = str(obj.numero_partida).strip()
# Construir el patrón exacto de búsqueda
patron_exacto = f'documents/vu_PT_{pedimentoApp}_{numero}.xml'
# Buscar documentos que empiecen EXACTAMENTE con ese patrón
qs = Document.objects.filter(
archivo=patron_exacto
)
# Opción 2: Si puede tener diferentes extensiones
# patron_base = f'documents/vu_PT_{pedimentoApp}_{numero}'
# qs = Document.objects.filter(
# archivo__startswith=patron_base
# ).filter(
# archivo__in=[
# f'{patron_base}.xml',
# f'{patron_base}.pdf',
# f'{patron_base}.zip'
# ]
# )
# Filtro adicional por pedimento si el modelo Document tiene este campo
if hasattr(Document, 'pedimento'):
qs = qs.filter(pedimento=obj.pedimento)
# Filtro por organización
if hasattr(obj, 'organizacion') and obj.organizacion:
qs = qs.filter(organizacion=obj.organizacion)
serializer = DocumentSerializer(qs, many=True, context=self.context)
return serializer.data
#return []
except Exception:
# En caso de cualquier error (por ejemplo, importaciones circulares), devolver lista vacía
return []
class Meta:
model = Partida
fields = '__all__'
@@ -129,6 +183,47 @@ class ProcesamientoPedimentoSerializer(serializers.ModelSerializer):
return representation
class EDocumentSerializer(serializers.ModelSerializer):
documentos = serializers.SerializerMethodField()
def get_documentos(self, obj):
"""
Busca documentos en la tabla `document` que coincidan con el
`numero_edocument` dentro del nombre del archivo (`archivo`). Se
filtra por organización para evitar devolver documentos de otras orgs.
Devuelve la serialización completa de los documentos encontrados:
1. Empiecen con 'vu_EDOCUMENT' en el nombre del archivo
2. Terminen con el numero_edocument + .xml
3. Pertenezcan a la misma organización
"""
if not obj or not getattr(obj, 'numero_edocument', None):
return []
if not obj or not getattr(obj, 'pedimento', None):
return []
# if not obj or not getattr(obj, 'pedimento_id', None):
# return []
try:
numero = str(obj.numero_edocument).strip()
# id_pedimento = str(obj.pedimento_id).strip()
qs = Document.objects.filter(
pedimento=obj.pedimento,
archivo__icontains=numero,
)
# Filtro por organización si aplica
if hasattr(obj, 'organizacion') and obj.organizacion:
qs = qs.filter(organizacion=obj.organizacion)
serializer = DocumentSerializer(qs, many=True, context=self.context)
return serializer.data
except Exception:
# En caso de cualquier error (por ejemplo, importaciones circulares), devolver lista vacía
return []
class Meta:
model = EDocument
fields = '__all__'
@@ -142,11 +237,48 @@ class EDocumentSerializer(serializers.ModelSerializer):
self.fields['organizacion'].read_only = True
class CoveSerializer(serializers.ModelSerializer):
documentos = serializers.SerializerMethodField()
class Meta:
model = Cove
fields = '__all__'
read_only_fields = ('created_at', 'updated_at')
def get_documentos(self, obj):
"""
Busca documentos en la tabla `document` que coincidan con el
`numero_cove` dentro del nombre del archivo (`archivo`). Se
filtra por organización para evitar devolver documentos de otras orgs.
Devuelve la serialización completa de los documentos encontrados:
1. Empiecen con 'vu_COVE' en el nombre del archivo
2. Terminen con el numero_cove + .xml
3. Pertenezcan a la misma organización
"""
if not obj or not getattr(obj, 'numero_cove', None):
return []
if not obj or not getattr(obj, 'pedimento', None):
return []
try:
numero = str(obj.numero_cove).strip()
qs = Document.objects.filter(
pedimento=obj.pedimento,
archivo__icontains=numero,
)
# Filtro por organización si aplica
if hasattr(obj, 'organizacion') and obj.organizacion:
qs = qs.filter(organizacion=obj.organizacion)
serializer = DocumentSerializer(qs, many=True, context=self.context)
return serializer.data
except Exception:
# En caso de cualquier error (por ejemplo, importaciones circulares), devolver lista vacía
return []
class ImportadorSerializer(serializers.ModelSerializer):
class Meta:
model = Importador

File diff suppressed because it is too large Load Diff

View File

@@ -9,10 +9,11 @@ from api.customs.models import Pedimento
class DocumentSerializer(serializers.ModelSerializer):
pedimento_numero = serializers.SerializerMethodField(read_only=True)
pedimento = serializers.PrimaryKeyRelatedField(queryset=Pedimento.objects.all())
fuente_nombre = serializers.SerializerMethodField()
fuente = serializers.PrimaryKeyRelatedField(queryset=Fuente.objects.all())
class Meta:
model = Document
fields = ('id', 'organizacion', 'pedimento', 'pedimento_numero', 'archivo', 'document_type', 'size', 'extension', 'fuente','created_at', 'updated_at')
fields = ('id', 'organizacion', 'pedimento', 'pedimento_numero', 'archivo', 'document_type', 'size', 'extension', 'fuente','fuente_nombre','created_at', 'updated_at')
read_only_fields = ('id', 'size', 'extension', 'created_at', 'updated_at', 'pedimento_numero')
def get_pedimento_numero(self, obj):
@@ -26,6 +27,12 @@ class DocumentSerializer(serializers.ModelSerializer):
raise serializers.ValidationError("Se requiere un archivo para subir")
return value
def get_fuente_nombre(self, obj):
# Método 1: Si la fuente está precargada con select_related
if obj.fuente:
return obj.fuente.nombre
return "Desconocido"
class FuenteSerializer(serializers.ModelSerializer):
class Meta:
model = Fuente

View File

@@ -4,12 +4,21 @@ from rest_framework.routers import DefaultRouter
# import necessary viewsets
# from .views import YourViewSet # Import your viewsets here
from .views import DocumentViewSet, ProtectedDocumentDownloadView, BulkDownloadZipView, GetFuenteView, DocumentTypeView
from .views import (DocumentViewSet
, ProtectedDocumentDownloadView
, BulkDownloadZipView
, GetFuenteView
, DocumentTypeView
, ExpedienteZipDownloadView
, MultiPedimentoZipDownloadView
, PedimentoDocumentViewSet)
# Create a router and register your viewsets with it
router = DefaultRouter()
# Register your viewsets with the router here
# Register your viewsets with the router he -fre
# Example:
# from .views import MyViewSet
# router.register(r'myviewset', MyViewSet, basename='myviewset')
@@ -23,5 +32,8 @@ urlpatterns = [
path('documents/descargar/<uuid:pk>/', ProtectedDocumentDownloadView.as_view(), name='descargar-documento'),
path('fuente/', GetFuenteView.as_view(), name='get-fuente'),
path('document-type/', DocumentTypeView.as_view(), name='document-type-list-create'),
path('documents/expediente-zip/', ExpedienteZipDownloadView.as_view(), name='expediente-zip-download'),
path('documents/multi-pedimento-zip/', MultiPedimentoZipDownloadView.as_view(), name='multi-pedimento-zip-download'),
path('pedimento-documents/', PedimentoDocumentViewSet.as_view({'get': 'list'}), name='pedimento-document-list'),
path('', include(router.urls)),
]

View File

@@ -13,6 +13,7 @@ from rest_framework.exceptions import ValidationError
from .serializers import DocumentSerializer, FuenteSerializer, DocumentTypeSerializer
from .models import Document, Fuente, DocumentType
from ..customs.models import Pedimento
from api.organization.models import UsoAlmacenamiento
from io import BytesIO
import zipfile
@@ -32,6 +33,9 @@ from core.permissions import (
import logging
logger = logging.getLogger(__name__)
import os
from django.core.files.storage import default_storage
from mixins.filtrado_organizacion import DocumentosFiltradosMixin
class CustomPagination(PageNumberPagination):
@@ -59,7 +63,8 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
pagination_class = CustomPagination
serializer_class = DocumentSerializer
# Habilitar filtro por pedimento (UUID) y pedimento_numero (campo pedimento del modelo relacionado)
filterset_fields = ['extension', 'size', 'document_type', 'pedimento', 'pedimento__pedimento']
filterset_fields = ['extension', 'size', 'document_type', 'pedimento', 'pedimento__pedimento', 'created_at']
# filterset_fields = ['extension', 'size', 'pedimento', 'pedimento__pedimento']
# Puedes filtrar por pedimento usando: /api/record/documents/?pedimento=<id> o /api/record/documents/?pedimento__pedimento=<numero>
# Ejemplo: /api/record/documents/?pedimento_numero=12345678
@@ -67,6 +72,33 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
def get_queryset(self):
queryset = self.get_queryset_filtrado_por_organizacion()
modulo_efc = self.request.query_params.get('modulo')
if modulo_efc:
if modulo_efc == 'expedientes-detalle-pedimentos':
queryset = queryset.exclude(document_type_id__in=['1','2','3','4','5','6','7','8','9','10'])
# Filtro personalizado por document_type
# document_type = self.request.query_params.get('document_type')
# if document_type:
# # Puedes agregar lógica personalizada aquí si es necesario
# if document_type == '1':
# queryset = queryset.filter(document_type_id=document_type)
# elif document_type == '2':
# queryset = queryset.filter(document_type_id=document_type)
# else:
# queryset = queryset.filter(document_type_id=document_type)
# else:
# queryset = queryset.filter(document_type_id='11')
fechaCreacion = self.request.query_params.get('created_at__date')
if fechaCreacion:
queryset = queryset.filter(created_at=fechaCreacion)
buscarArchivo = self.request.query_params.get('archivo__icontains')
if buscarArchivo:
queryset = queryset.filter(archivo__icontains=buscarArchivo)
pedimento_numero = self.request.query_params.get('pedimento_numero')
if pedimento_numero:
queryset = queryset.filter(pedimento__pedimento_app=pedimento_numero)
@@ -531,7 +563,6 @@ class ProtectedDocumentDownloadView(APIView, DocumentosFiltradosMixin):
if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'):
raise Http404("Usuario no autenticado")
try:
doc = Document.objects.get(pk=pk)
except Document.DoesNotExist:
@@ -618,3 +649,191 @@ class DocumentTypeView(APIView):
return Response({"detail": "No hay tipos de documento disponibles."}, status=404)
serializer = self.serializer_class(queryset, many=True)
return Response(serializer.data, status=200)
class ExpedienteZipDownloadView(APIView, DocumentosFiltradosMixin):
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
my_tags = ['Documents']
def post(self, request):
"""
Descarga todos los documentos de un pedimento (o filtrados) en un ZIP.
Body: { "pedimento_id": "<uuid>" }
"""
pedimento_id = request.data.get('pedimento_id')
if not pedimento_id:
return Response({"error": "Falta pedimento_id"}, status=status.HTTP_400_BAD_REQUEST)
# Validar que el pedimento existe
try:
pedimento = Pedimento.objects.get(pk=pedimento_id)
except Pedimento.DoesNotExist:
raise Http404("Pedimento no encontrado")
# Filtrar documentos del pedimento (y de la org del usuario)
base_qs = Document.objects.filter(pedimento=pedimento)
if not request.user.is_superuser:
if not hasattr(request.user, 'organizacion') or request.user.organizacion != pedimento.organizacion:
return Response({"error": "No autorizado"}, status=status.HTTP_403_FORBIDDEN)
base_qs = base_qs.filter(organizacion=request.user.organizacion)
docs = base_qs.select_related('pedimento')
if not docs.exists():
return Response({"error": "No hay documentos para este pedimento"}, status=status.HTTP_404_NOT_FOUND)
# 1. Crear un único buffer y ZIP para todos los archivos
buffer = BytesIO()
missing_files = [] # opcional: para informar después
files_found = []
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
for doc in docs:
# 2. Validaciones
if not doc.archivo.name:
logger.warning("Documento %s no tiene archivo asociado", doc.id)
missing_files.append(f"{doc.id} (sin archivo)")
continue
if not default_storage.exists(doc.archivo.name):
logger.warning("Archivo no encontrado en disco: %s", doc.archivo.path)
missing_files.append(f"{doc.id} ({doc.archivo.name})")
continue
files_found.append(f"{doc.id} ({doc.archivo.name})")
# 3. Nombre seguro para dentro del ZIP
file_name = slugify(doc.archivo.name.rsplit('/', 1)[-1].rsplit('.', 1)[0])
ext = doc.archivo.name.split('.')[-1]
name_inside_zip = f"{file_name}.{ext}"
# 4. Escribir el archivo dentro del ZIP
with doc.archivo.open('rb') as f:
zip_file.writestr(name_inside_zip, f.read())
# 5. Preparar respuesta
buffer.seek(0)
zip_name = slugify(f"expediente_{pedimento.pedimento_app}")
response = HttpResponse(buffer, content_type='application/zip')
response['Content-Disposition'] = f'attachment; filename={zip_name or "documentos"}.zip'
if not files_found:
return Response({"error": f"No hay documentos para este pedimento: {pedimento.pedimento_app}"}, status=status.HTTP_404_NOT_FOUND)
# (Opcional) cabecera personalizada si faltaron archivos
# if missing_files:
# response['X-Missing-Files'] = ', '.join(missing_files)
# return Response({"error": f"No hay documentos para este pedimento: {pedimento.pedimento_app}"}, status=status.HTTP_404_NOT_FOUND)
return response
class MultiPedimentoZipDownloadView(APIView):
permission_classes = [IsAuthenticated & (IsSuperUser | IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper)]
my_tags = ['Documents']
def post(self, request):
"""
Descarga todos los documentos de VARIOS pedimentos en un solo ZIP.
Body: { "pedimento_ids": ["uuid1", "uuid2", ...] }
"""
pedimento_ids = request.data.get('pedimento_ids', [])
if not isinstance(pedimento_ids, list) or not pedimento_ids:
return Response({"error": "Se requiere una lista de pedimento_ids"}, status=status.HTTP_400_BAD_REQUEST)
# Filtrar pedimentos visibles para el usuario
base_qs = Pedimento.objects.filter(id__in=pedimento_ids)
if not request.user.is_superuser:
if not hasattr(request.user, 'organizacion'):
return Response({"error": "No autorizado"}, status=status.HTTP_403_FORBIDDEN)
base_qs = base_qs.filter(organizacion=request.user.organizacion)
pedimentos = base_qs.select_related('organizacion')
if not pedimentos.exists():
return Response({"error": "Ningún pedimento encontrado o autorizado"}, status=status.HTTP_404_NOT_FOUND)
# Obtener todos los documentos de esos pedimentos
docs = Document.objects.filter(pedimento__in=pedimentos)
if not docs.exists():
return Response({"error": "No hay documentos para estos pedimentos"}, status=status.HTTP_404_NOT_FOUND)
# Crear ZIP único
buffer = BytesIO()
missing_files = []
summary = {}
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
for doc in docs:
ped_key = doc.pedimento.pedimento_app
if not doc.archivo.name or not default_storage.exists(doc.archivo.name):
missing_files.append(f"{doc.id} ({doc.archivo.name or 'sin archivo'})")
logger.warning("Archivo faltante: %s", doc.id)
continue
summary[ped_key] = summary.get(ped_key, 0) + 1
# Nombre seguro: pedimento_app + nombre del archivo
file_name = slugify(doc.archivo.name.rsplit('/', 1)[-1].rsplit('.', 1)[0])
ext = doc.archivo.name.split('.')[-1]
name_inside_zip = f"{doc.pedimento.pedimento_app}/{file_name}.{ext}"
with doc.archivo.open('rb') as f:
zip_file.writestr(name_inside_zip, f.read())
buffer.seek(0)
zip_name = slugify(f"expedientes_{len(summary)}_pedimentos")
response = HttpResponse(buffer, content_type='application/zip')
response['Content-Disposition'] = f'attachment; filename={zip_name}.zip'
response['X-Zip-Filename'] = f"{zip_name}.zip"
response['Access-Control-Expose-Headers'] = 'X-Zip-Filename'
if missing_files:
response['X-Missing-Files'] = ', '.join(missing_files)
return response
class PedimentoDocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
"""
ViewSet for Document model.
"""
permission_classes = [IsAuthenticated & (IsSuperUser | IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper )]
model = Document
pagination_class = CustomPagination
serializer_class = DocumentSerializer
# Habilitar filtro por pedimento (UUID) y pedimento_numero (campo pedimento del modelo relacionado)
# filterset_fields = ['extension', 'size', 'document_type', 'pedimento', 'pedimento__pedimento']
filterset_fields = ['extension', 'size', 'pedimento', 'pedimento__pedimento','fuente']
# Puedes filtrar por pedimento usando: /api/record/documents/?pedimento=<id> o /api/record/documents/?pedimento__pedimento=<numero>
# Ejemplo: /api/record/documents/?pedimento_numero=12345678
my_tags = ['Documents']
def get_queryset(self):
queryset = self.get_queryset_filtrado_por_organizacion()
# Tipos de documento permitidos (fijos en código, Pedimento completo y remesas)
TIPOS_PERMITIDOS = ['2', '3'] # <-- Ajusta aquí tus tipos
tipo_documento = self.request.query_params.get('document_type')
if tipo_documento:
queryset = queryset.filter(document_type_id=tipo_documento)
else:
# Filtrar por tipos permitidos
queryset = queryset.filter(document_type_id__in=TIPOS_PERMITIDOS)
buscar_archivo = self.request.query_params.get('archivo__icontains')
if buscar_archivo:
queryset = queryset.filter(archivo__icontains=buscar_archivo)
created_at__date = self.request.query_params.get('created_at__date')
if created_at__date:
queryset = queryset.filter(created_at=created_at__date)
# Filtro adicional por pedimento_numero si se proporciona
pedimento_numero = self.request.query_params.get('pedimento_numero')
if pedimento_numero:
queryset = queryset.filter(pedimento__pedimento_app=pedimento_numero)
return queryset

View File

@@ -1,4 +1,5 @@
from celery import shared_task
from api.organization.models import Organizacion
from django.core.files.base import ContentFile
from django.utils import timezone
from api.reports.models import ReportDocument
@@ -112,12 +113,39 @@ def generate_report_control_pedimento(report_id):
pedimento_ids = list(pedimentos_qs.values_list('id', flat=True))
rfcs_raw = list(pedimentos_qs.values_list('agente_aduanal', flat=True))
# inicializar totales
pedimentos_completos = 0
total_documentos = 0
documentos_sin_descargar = 0
nombre_organizacion = ''
if filters.get('organizacion_id'):
try:
# Asumo que tienes un modelo Organizacion - ajusta según tu modelo real
organizacion = Organizacion.objects.get(id=filters['organizacion_id'])
nombre_organizacion = organizacion.nombre # ajusta el campo según tu modelo
except Organizacion.DoesNotExist:
nombre_organizacion = f"ID: {filters['organizacion_id']}"
except Exception as e:
nombre_organizacion = f"Error: {str(e)}"
# lista de rfc
rfc_list = ', '.join(sorted(set([rfc for rfc in rfcs_raw if rfc])))
fecha_inicio = ''
fecha_fin = ''
if pedimentos_qs.exists():
primer_pedimento = pedimentos_qs.order_by('fecha_pago').first()
if primer_pedimento and primer_pedimento.fecha_pago:
fecha_inicio = primer_pedimento.fecha_pago.strftime('%Y-%m-%d')
ultimo_pedimento = pedimentos_qs.order_by('-fecha_pago').first()
if ultimo_pedimento and ultimo_pedimento.fecha_pago:
fecha_fin = ultimo_pedimento.fecha_pago.strftime('%Y-%m-%d')
# Para cada pedimento, verificar si está completo
for pedimento in pedimentos_qs:
# Contar documentos de este pedimento
@@ -216,12 +244,15 @@ def generate_report_control_pedimento(report_id):
# SECCIÓN DE TOTALES
writer.writerow(['RESUMEN DEL REPORTE - CONTROL DE PEDIMENTOS'])
writer.writerow(['ORGANIZACION:', nombre_organizacion])
writer.writerow([])
writer.writerow(['TOTAL DE EXPEDIENTES:', pedimentos_total])
writer.writerow(['TOTAL DE EXPEDIENTES COMPLETOS:', pedimentos_completos])
writer.writerow(['TOTAL DE DOCUMENTOS:', total_documentos])
writer.writerow(['DOCUMENTOS SIN DESCARGAR:', documentos_sin_descargar])
writer.writerow(['PORCENTAJE DE DOCUMENTOS FALTANTES (%):', f"{porcentaje_faltantes:.2f}%"])
writer.writerow(['DESDE: ', fecha_inicio, ' HASTA: ', fecha_fin])
writer.writerow(['LISTA RFC:', rfc_list])
writer.writerow([])
writer.writerow([])

View File

@@ -114,7 +114,7 @@ class ExportDataStageView(APIView):
# Constantes para partición
# MAX_RECORDS_PER_FILE = 100 # Límite seguro por archivo
MAX_RECORDS_PER_FILE = 50000 # Límite seguro por archivo
MAX_RECORDS_PER_FILE = 120000 # Límite seguro por archivo
def safe_excel_value(self, value):
"""
@@ -191,16 +191,15 @@ class ExportDataStageView(APIView):
return Response({'error': 'models are required for multiple export'}, status=status.HTTP_400_BAD_REQUEST)
related_keys = self.get_related_keys_from_filters(global_filters, models_data, request.user)
total_estimated_records = self.estimate_total_records(models_data, global_filters, related_keys, request.user)
if total_estimated_records > self.MAX_RECORDS_PER_FILE:
if export_type == 'excel':
return self.export_datastage_multiple_partitioned_excel(request, models_data, global_filters, related_keys)
else:
return self.export_datastage_multiple_partitioned_csv(request, models_data, global_filters, related_keys)
if export_type == 'excel':
# Siempre usar el método particionado inteligente para Excel
return self.export_datastage_multiple_partitioned_excel(request, models_data, global_filters, related_keys)
else:
if export_type == 'excel':
return self.export_datastage_multiple_to_excel(request, models_data, global_filters, related_keys)
# 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)
@@ -281,11 +280,15 @@ class ExportDataStageView(APIView):
return response
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"""
"""Exporta múltiples modelos de DataStage a múltiples archivos Excel particionados inteligentemente"""
try:
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
file_counter = 1
current_wb = None
current_file_records_count = 0
MAX_SHEETS_PER_FILE = 10 # Límite de hojas por archivo Excel
for model_data in models_data:
model_name = model_data.get('model')
@@ -298,17 +301,17 @@ class ExportDataStageView(APIView):
model = apps.get_model('datastage', model_name)
filters = self.apply_related_filters(global_filters, model, related_keys, request.user)
# Si hay filtros, aplicarlos; si no, obtener todos los registros
if filters:
queryset = model.objects.filter(**filters).values(*fields)
else:
queryset = model.objects.none() # No obtener nada si no hay filtros
queryset = model.objects.none()
total_records = queryset.count()
if total_records == 0:
continue
# Si el modelo necesita particionarse (más de MAX_RECORDS_PER_FILE)
if total_records > self.MAX_RECORDS_PER_FILE:
from django.core.paginator import Paginator
paginator = Paginator(queryset, self.MAX_RECORDS_PER_FILE)
@@ -316,29 +319,62 @@ class ExportDataStageView(APIView):
for page_num in paginator.page_range:
page = paginator.page(page_num)
wb = openpyxl.Workbook()
ws = wb.active
ws.title = f"Parte_{page_num}"[:31]
# Verificar si necesitamos crear nuevo archivo
# 1. Si no hay archivo actual
# 2. Si ya tenemos muchas hojas en este archivo
# 3. Si este archivo ya está "lleno" (muchos registros)
if (current_wb is None or
len(current_wb.sheetnames) >= MAX_SHEETS_PER_FILE or
current_file_records_count > self.MAX_RECORDS_PER_FILE * 3): # ~150K registros
if current_wb is not None:
# Guardar archivo actual en ZIP
part_buffer = io.BytesIO()
current_wb.save(part_buffer)
part_buffer.seek(0)
zip_file.writestr(f"datastage_part{file_counter}.xlsx", part_buffer.getvalue())
file_counter += 1
# Crear nuevo workbook
current_wb = openpyxl.Workbook()
current_wb.remove(current_wb.active) # Remover hoja por defecto
current_file_records_count = 0
# Crear hoja para esta parte del modelo
sheet_name = f"{model_name[:25]}_p{page_num}"
ws = current_wb.create_sheet(title=sheet_name[:31])
ws.append(fields)
# Escribir datos
for row in page.object_list:
row_values = [self.safe_excel_value(row[field]) for field in fields]
ws.append(row_values)
# Guardar parte en ZIP
part_buffer = io.BytesIO()
wb.save(part_buffer)
part_buffer.seek(0)
filename = f"{model_name}_part{page_num}.xlsx"
zip_file.writestr(filename, part_buffer.getvalue())
current_file_records_count += len(page.object_list)
else:
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Datos"[:31]
# Modelo pequeño (≤ MAX_RECORDS_PER_FILE)
# Verificar si necesitamos nuevo archivo
if (current_wb is None or
len(current_wb.sheetnames) >= MAX_SHEETS_PER_FILE or
current_file_records_count + total_records > self.MAX_RECORDS_PER_FILE * 3):
if current_wb is not None:
# Guardar archivo actual
part_buffer = io.BytesIO()
current_wb.save(part_buffer)
part_buffer.seek(0)
zip_file.writestr(f"datastage_part{file_counter}.xlsx", part_buffer.getvalue())
file_counter += 1
# Crear nuevo workbook
current_wb = openpyxl.Workbook()
current_wb.remove(current_wb.active)
current_file_records_count = 0
# Crear hoja para este modelo
sheet_name = model_name[:31]
ws = current_wb.create_sheet(title=sheet_name)
ws.append(fields)
# Escribir datos
@@ -346,25 +382,22 @@ class ExportDataStageView(APIView):
row_values = [self.safe_excel_value(row[field]) for field in fields]
ws.append(row_values)
part_buffer = io.BytesIO()
wb.save(part_buffer)
part_buffer.seek(0)
current_file_records_count += total_records
filename = f"{model_name}.xlsx"
zip_file.writestr(filename, part_buffer.getvalue())
except LookupError:
continue
except LookupError as e:
continue
except Exception as e:
continue
# Guardar el último workbook si existe
if current_wb is not None:
part_buffer = io.BytesIO()
current_wb.save(part_buffer)
part_buffer.seek(0)
zip_file.writestr(f"datastage_part{file_counter}.xlsx", part_buffer.getvalue())
zip_buffer.seek(0)
zip_content = zip_buffer.getvalue()
response = HttpResponse(zip_content, content_type='application/zip')
response = HttpResponse(zip_buffer.read(), content_type='application/zip')
response['Content-Disposition'] = 'attachment; filename="datastage_reports.zip"'
response['Content-Length'] = len(zip_content)
return response
except Exception as e:
@@ -578,60 +611,48 @@ 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
para usarlos como relación entre modelos
VERSIÓN SIMPLIFICADA - Usa la MISMA lógica que apply_global_filters_to_model
"""
related_keys = {
'patentes': set(),
'pedimentos': set(),
'datastage_ids': set()
}
# Si no hay filtros globales, retornar vacío (no hay relación)
if not any(global_filters.values()):
# Si no hay filtros, retornar vacío
if not any(v for v in global_filters.values() if v not in [None, '']):
return {}
all_records_with_filters = []
# Buscar en TODOS los modelos que puedan tener los campos de filtro
for model_data in models_data:
model_name = model_data.get('model')
try:
model = apps.get_model('datastage', model_name)
model_fields = [f.name for f in model._meta.get_fields()]
# Construir filtros EXACTOS con TODOS los campos disponibles
filters = {}
has_any_filter = False
# ¡USAR LA MISMA FUNCIÓN QUE EN MODO SINGULAR!
filters = self.apply_global_filters_to_model(global_filters, model, user)
if 'organizacion' in model_fields and global_filters.get('organizacion'):
filters['organizacion'] = global_filters['organizacion']
has_any_filter = True
if filters:
# EJECUTAR CONSULTA - IDÉNTICO A MODO SINGULAR
queryset = model.objects.filter(**filters)
total = queryset.count()
if 'patente' in model_fields and global_filters.get('patente'):
filters['patente'] = global_filters['patente']
has_any_filter = True
# 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 'pedimento' in model_fields and global_filters.get('pedimento'):
filters['pedimento'] = global_filters['pedimento']
has_any_filter = True
if rfc_exact_count != total:
try:
other_rfcs = queryset.exclude(rfc=rfc_value).values_list('rfc', flat=True).distinct()[:5]
except:
pass
if 'rfc' in model_fields and global_filters.get('rfc'):
filters['rfc'] = global_filters['rfc']
has_any_filter = True
if 'fecha_pago_real' in model_fields:
if global_filters.get('fecha_pago_desde'):
filters['fecha_pago_real__gte'] = global_filters['fecha_pago_desde']
has_any_filter = True
if global_filters.get('fecha_pago_hasta'):
filters['fecha_pago_real__lte'] = global_filters['fecha_pago_hasta']
has_any_filter = True
if has_any_filter:
records = model.objects.filter(**filters).values('patente', 'pedimento', 'datastage_id')
record_count = records.count()
# Obtener registros
records = queryset.values('patente', 'pedimento', 'datastage_id')
all_records_with_filters.extend(list(records))
except LookupError:
@@ -648,29 +669,47 @@ class ExportDataStageView(APIView):
if record.get('datastage_id'):
related_keys['datastage_ids'].add(record['datastage_id'])
related_keys = {k: list(v) for k, v in related_keys.items() if v}
return related_keys
return {k: list(v) for k, v in related_keys.items() if v}
def apply_global_filters_to_model(self, global_filters, model, user):
"""
Aplica filtros globales específicamente para modelos DataStage (modo simple)
Aplica filtros globales - VERSIÓN CORREGIDA CON UUID
"""
filters = {}
model_fields = [f.name for f in model._meta.get_fields()]
if 'organizacion' in model_fields and global_filters.get('organizacion'):
filters['organizacion'] = global_filters['organizacion']
# ORGANIZACIÓN - Manejar como UUID
org_value = global_filters.get('organizacion')
if org_value and org_value != '' and 'organizacion' in model_fields:
field = model._meta.get_field('organizacion')
if 'patente' in model_fields and global_filters.get('patente'):
if hasattr(field, 'related_model'): # Es ForeignKey
# Convertir string a UUID
try:
import uuid
org_uuid = uuid.UUID(org_value)
filters['organizacion_id'] = org_uuid
except Exception as e:
# Fallback: dejar como string (puede no funcionar)
filters['organizacion_id'] = org_value
else: # Es CharField
filters['organizacion'] = org_value
# RFC - Manejar normalmente
rfc_value = global_filters.get('rfc')
if rfc_value and rfc_value != '' and 'rfc' in model_fields:
filters['rfc'] = rfc_value
# PATENTE
if global_filters.get('patente'):
filters['patente'] = global_filters['patente']
if 'pedimento' in model_fields and global_filters.get('pedimento'):
# PEDIMENTO
if global_filters.get('pedimento'):
filters['pedimento'] = global_filters['pedimento']
if 'rfc' in model_fields and global_filters.get('rfc'):
filters['rfc'] = global_filters['rfc']
# FECHAS
if 'fecha_pago_real' in model_fields:
if global_filters.get('fecha_pago_desde'):
filters['fecha_pago_real__gte'] = global_filters['fecha_pago_desde']
@@ -681,62 +720,163 @@ class ExportDataStageView(APIView):
return filters
def apply_related_filters(self, global_filters, model, related_keys, user):
"""
Aplica filtros relacionados basados en campos comunes de manera ESTRICTA - VERSIÓN CORREGIDA
"""
filters = {}
model_fields = [f.name for f in model._meta.get_fields()]
# 🔥 ESTRATEGIA MEJORADA: Usar claves relacionadas SI HAY, sino aplicar filtros directos SOLO si existen
has_related_keys = any(related_keys.values())
if has_related_keys:
# 🔥 MODO RELACIONADO ESTRICTO: Usar SOLO las claves obtenidas
# Crear condiciones para las claves relacionadas
from django.db.models import Q
related_conditions = Q()
has_related_conditions = False
if related_keys.get('patentes') and 'patente' in model_fields:
filters['patente__in'] = related_keys['patentes']
has_related_conditions = True
if related_keys.get('pedimentos') and 'pedimento' in model_fields:
filters['pedimento__in'] = related_keys['pedimentos']
has_related_conditions = True
if related_keys.get('datastage_ids') and 'datastage_id' in model_fields:
filters['datastage_id__in'] = related_keys['datastage_ids']
has_related_conditions = True
# Si NO HAY condiciones relacionadas para este modelo (no tiene los campos)
if not has_related_conditions:
return {} # Retornar filtro vacío hará que no se obtengan registros
else:
# 🔥 MODO DIRECTO: No hay claves relacionadas, aplicar filtros directos SOLO si existen
if 'organizacion' in model_fields and global_filters.get('organizacion'):
filters['organizacion'] = global_filters['organizacion']
if 'patente' in model_fields and global_filters.get('patente'):
filters['patente'] = global_filters['patente']
if 'pedimento' in model_fields and global_filters.get('pedimento'):
filters['pedimento'] = global_filters['pedimento']
if 'rfc' in model_fields and global_filters.get('rfc'):
filters['rfc'] = global_filters['rfc']
# 🔥 APLICAR ORGANIZACIÓN SIEMPRE si existe (en ambos modos)
# 1. Organización
if 'organizacion' in model_fields and global_filters.get('organizacion'):
filters['organizacion'] = global_filters['organizacion']
# 🔥 APLICAR FILTROS DE FECHA SIEMPRE (si el campo existe)
# 2. RFC (¡ESTO ES LO QUE FALTA!)
if 'rfc' in model_fields and global_filters.get('rfc'):
filters['rfc'] = global_filters['rfc']
# 3. Fechas (SIEMPRE se aplican)
if 'fecha_pago_real' in model_fields:
if global_filters.get('fecha_pago_desde'):
filters['fecha_pago_real__gte'] = global_filters['fecha_pago_desde']
if global_filters.get('fecha_pago_hasta'):
filters['fecha_pago_real__lte'] = global_filters['fecha_pago_hasta']
# 🔥 SEGUNDO: Si hay related_keys, AÑADIRLAS a los filtros existentes
if any(related_keys.values()):
# Añadir patentes si existen
if related_keys.get('patentes') and 'patente' in model_fields:
filters['patente__in'] = related_keys['patentes']
# Añadir pedimentos si existen
if related_keys.get('pedimentos') and 'pedimento' in model_fields:
filters['pedimento__in'] = related_keys['pedimentos']
# Añadir datastage_ids si existen
if related_keys.get('datastage_ids') and 'datastage_id' in model_fields:
filters['datastage_id__in'] = related_keys['datastage_ids']
else:
# Solo patente y pedimento específicos (no listas)
if 'patente' in model_fields and global_filters.get('patente'):
filters['patente'] = global_filters['patente']
if 'pedimento' in model_fields and global_filters.get('pedimento'):
filters['pedimento'] = global_filters['pedimento']
return filters
def estimate_excel_file_size(self, num_records, num_columns):
"""Estima tamaño aproximado del archivo Excel"""
# Estimación aproximada: 100 bytes por celda
return num_records * num_columns * 100
def export_with_size_control(self, request, models_data, global_filters, related_keys):
"""Versión con control de tamaño de archivo"""
try:
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
file_counter = 1
current_wb = None
current_file_size_estimate = 0
MAX_FILE_SIZE_ESTIMATE = 50 * 1024 * 1024 # 50MB estimado
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
try:
model = apps.get_model('datastage', model_name)
filters = self.apply_related_filters(global_filters, model, related_keys, request.user)
if filters:
queryset = model.objects.filter(**filters).values(*fields)
else:
queryset = model.objects.none()
total_records = queryset.count()
if total_records == 0:
continue
# Calcular tamaño estimado para este modelo
model_size_estimate = self.estimate_excel_file_size(total_records, len(fields))
# Si el modelo es muy grande o no cabe en el archivo actual
needs_new_file = (
current_wb is None or
current_file_size_estimate + model_size_estimate > MAX_FILE_SIZE_ESTIMATE or
(total_records > self.MAX_RECORDS_PER_FILE and current_file_size_estimate > 0)
)
if needs_new_file and current_wb is not None:
# Guardar archivo actual
part_buffer = io.BytesIO()
current_wb.save(part_buffer)
part_buffer.seek(0)
zip_file.writestr(f"datastage_part{file_counter}.xlsx", part_buffer.getvalue())
file_counter += 1
current_wb = None
current_file_size_estimate = 0
if current_wb is None:
current_wb = openpyxl.Workbook()
current_wb.remove(current_wb.active)
# Manejar modelos que exceden el límite por hoja
if total_records > self.MAX_RECORDS_PER_FILE:
from django.core.paginator import Paginator
paginator = Paginator(queryset, self.MAX_RECORDS_PER_FILE)
for page_num in paginator.page_range:
page = paginator.page(page_num)
# Crear hoja para esta parte
sheet_name = f"{model_name[:20]}_p{page_num}"[:31]
ws = current_wb.create_sheet(title=sheet_name)
ws.append(fields)
for row in page.object_list:
row_values = [self.safe_excel_value(row[field]) for field in fields]
ws.append(row_values)
# Actualizar tamaño estimado
page_size = self.estimate_excel_file_size(len(page.object_list), len(fields))
current_file_size_estimate += page_size
else:
# Modelo pequeño, una hoja
sheet_name = model_name[:31]
ws = current_wb.create_sheet(title=sheet_name)
ws.append(fields)
for row in queryset:
row_values = [self.safe_excel_value(row[field]) for field in fields]
ws.append(row_values)
current_file_size_estimate += model_size_estimate
except LookupError:
continue
# Guardar último archivo si existe
if current_wb is not None:
part_buffer = io.BytesIO()
current_wb.save(part_buffer)
part_buffer.seek(0)
zip_file.writestr(f"datastage_part{file_counter}.xlsx", part_buffer.getvalue())
zip_buffer.seek(0)
response = HttpResponse(zip_buffer.read(), content_type='application/zip')
response['Content-Disposition'] = 'attachment; filename="datastage_reports.zip"'
return response
except Exception as e:
return Response({'error': f'Error: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class ExportModelView(APIView):
my_tags = ['Reportes']
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]