11 Commits

Author SHA1 Message Date
Dulce
a112d746f6 eliminar loggers 2025-12-16 08:22:59 -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
9 changed files with 1251 additions and 144 deletions

2
.gitignore vendored
View File

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

View File

@@ -3,12 +3,17 @@ FROM python:3.11-slim
WORKDIR /app 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 ./ COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
RUN pip install flower RUN pip install flower
COPY . . COPY . .

View File

@@ -3,8 +3,16 @@ FROM python:3.11-slim
WORKDIR /app WORKDIR /app
# Instalar dependencias del sistema # 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 \ 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/* && rm -rf /var/lib/apt/lists/*
# Copiar e instalar dependencias de Python # Copiar e instalar dependencias de Python

View File

@@ -9,6 +9,7 @@ from api.customs.models import (
Partida Partida
) )
from django.db import models 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.models import Document # Asegúrate de importar el modelo Documento
from api.record.serializers import DocumentSerializer from api.record.serializers import DocumentSerializer
from api.vucem.serializers import VucemSerializer from api.vucem.serializers import VucemSerializer
@@ -43,6 +44,59 @@ class PedimentoSerializer(serializers.ModelSerializer):
return rep return rep
class PartidaSerializer(serializers.ModelSerializer): 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: class Meta:
model = Partida model = Partida
fields = '__all__' fields = '__all__'
@@ -129,6 +183,47 @@ class ProcesamientoPedimentoSerializer(serializers.ModelSerializer):
return representation return representation
class EDocumentSerializer(serializers.ModelSerializer): 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: class Meta:
model = EDocument model = EDocument
fields = '__all__' fields = '__all__'
@@ -142,11 +237,48 @@ class EDocumentSerializer(serializers.ModelSerializer):
self.fields['organizacion'].read_only = True self.fields['organizacion'].read_only = True
class CoveSerializer(serializers.ModelSerializer): class CoveSerializer(serializers.ModelSerializer):
documentos = serializers.SerializerMethodField()
class Meta: class Meta:
model = Cove model = Cove
fields = '__all__' fields = '__all__'
read_only_fields = ('created_at', 'updated_at') 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 ImportadorSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Importador model = Importador

View File

@@ -38,7 +38,132 @@ from api.customs.serializers import (
from api.logger.mixins import LoggingMixin from api.logger.mixins import LoggingMixin
from mixins.filtrado_organizacion import OrganizacionFiltradaMixin, ProcesosPorOrganizacionMixin from mixins.filtrado_organizacion import OrganizacionFiltradaMixin, ProcesosPorOrganizacionMixin
import requests import requests
import os
import re
import zipfile
import tempfile
import shutil
import subprocess
from datetime import datetime
from django.core.files.base import ContentFile
from django.db import transaction
from rest_framework.parsers import MultiPartParser, FormParser
from api.record.models import Document, DocumentType
# Importar rarfile de manera opcional
try:
import rarfile
RAR_SUPPORT = True
except ImportError:
RAR_SUPPORT = False
def get_available_extractors():
"""
Devuelve lista de extractores disponibles en orden de preferencia
"""
extractors = []
if RAR_SUPPORT:
extractors.append('rarfile')
# Verificar si 'unrar' está disponible
if shutil.which('unrar'):
extractors.append('unrar')
# Verificar si '7z' o '7za' están disponibles
if shutil.which('7z'):
extractors.append('7z')
elif shutil.which('7za'):
extractors.append('7za')
return extractors
def extract_rar_to_dir(rar_path, dest_dir):
"""
Extrae un archivo RAR a `dest_dir` usando varios mecanismos de respaldo:
1) intentará usar la librería `rarfile` si está disponible,
2) intentará ejecutar la utilidad `unrar` si está instalada en el sistema,
3) intentará ejecutar `7z`/`7za` (p7zip) si está instalada.
Lanza Exception con mensaje explicativo si falla.
"""
# Versión que primero verifica herramientas disponibles
available = get_available_extractors()
if not available:
raise Exception("No hay herramientas de extracción disponibles.")
print(f"Extractores disponibles (en orden de preferencia): {available}")
# Intento con rarfile primero si está disponible
# if RAR_SUPPORT:
if 'rarfile' in available and RAR_SUPPORT:
try:
# rarfile puede trabajar con rutas en disco mejor que con file-like
with rarfile.RarFile(rar_path) as rf:
rf.extractall(dest_dir)
try:
if os.path.exists(rar_path):
os.remove(rar_path)
print(f"Archivo original eliminado: {rar_path}")
except OSError as remove_error:
print(f"Advertencia: No se pudo eliminar '{rar_path}': {remove_error}")
return
except Exception as e:
# Si rarfile falla (por ejemplo RarCannotExec), seguimos con herramientas externas
# Hacer log para depuración
print(f"rarfile extraction failed, will try external tools: {e}")
# Intento con comandos externos
# Probar 'unrar' primero
external_cmds = [
['unrar', 'x', '-o+', rar_path, dest_dir],
['7z', 'x', rar_path, f'-o{dest_dir}', '-y'],
['7za', 'x', rar_path, f'-o{dest_dir}', '-y']
]
# for cmd in external_cmds:
# try:
# subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
# try:
# if os.path.exists(rar_path):
# os.remove(rar_path)
# print(f"Archivo original eliminado: {rar_path}")
# except OSError as remove_error:
# print(f"Advertencia: No se pudo eliminar '{rar_path}': {remove_error}")
# return
# except FileNotFoundError:
# # El ejecutable no existe en PATH, intentar siguiente
# continue
# except subprocess.CalledProcessError as e:
# # El comando falló en la extracción; intentar siguiente
# print(f"External extractor failed ({cmd[0]}): {e}")
# continue
for extractor_name in available:
if extractor_name in external_cmds:
cmd = external_cmds[extractor_name]
try:
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
try:
if os.path.exists(rar_path):
os.remove(rar_path)
print(f"Archivo original eliminado: {rar_path}")
except OSError as remove_error:
print(f"Advertencia: No se pudo eliminar '{rar_path}': {remove_error}")
return
except FileNotFoundError:
# El ejecutable no existe en PATH, intentar siguiente
continue
except subprocess.CalledProcessError as e:
# El comando falló en la extracción; intentar siguiente
print(f"External extractor {extractor_name} failed ({cmd[0]}): {e}")
continue
# Si llegamos aquí, ningún método funcionó
raise Exception("No se encontró una herramienta válida para extraer RAR (rarfile sin backend, 'unrar' o '7z' no disponibles o extracción fallida). Instale 'unrar' o 'p7zip' y asegúrese de que estén en PATH, o configure rarfile con un backend.")
from .tasks.microservice_v2 import * from .tasks.microservice_v2 import *
from .tasks.auditoria import crear_partidas_por_pedimento from .tasks.auditoria import crear_partidas_por_pedimento
@@ -151,6 +276,9 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
filterset_fields = ['patente', 'aduana', 'tipo_operacion', 'clave_pedimento', 'pedimento', 'existe_expediente', 'contribuyente', 'curp_apoderado', 'fecha_pago', 'pedimento_app'] filterset_fields = ['patente', 'aduana', 'tipo_operacion', 'clave_pedimento', 'pedimento', 'existe_expediente', 'contribuyente', 'curp_apoderado', 'fecha_pago', 'pedimento_app']
search_fields = ['pedimento', 'pedimento_app', 'agente_aduanal', 'clave_pedimento'] search_fields = ['pedimento', 'pedimento_app', 'agente_aduanal', 'clave_pedimento']
# AGREGAR ESTOS CAMPOS PARA ORDENACIÓN
ordering_fields = ['created_at', 'pedimento', 'fecha_pago', 'aduana', 'patente']
ordering = ['-created_at'] # Orden descendente por fecha de creación por defecto
def get_queryset(self): def get_queryset(self):
return self.get_queryset_filtrado_por_organizacion() # Tambien filtra por importador return self.get_queryset_filtrado_por_organizacion() # Tambien filtra por importador
@@ -324,6 +452,438 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
return Response(response_data, status=response_status) return Response(response_data, status=response_status)
@action(detail=False, methods=['post'], url_path='bulk-create', parser_classes=[MultiPartParser, FormParser])
def bulk_create(self, request):
"""
Endpoint para crear múltiples pedimentos de manera masiva desde archivos.
FormData esperado:
- contribuyente: string (nombre del contribuyente)
- archivos: files (pueden ser múltiples archivos: zip, rar o individuales)
Nomenclatura esperada de archivos: anio-aduana-patente-pedimento
- anio: 2 dígitos (ej: 24)
- aduana: 2 o 3 dígitos (ej: 01, 123)
- patente: 4 dígitos (ej: 3420)
- pedimento: 7 dígitos (ej: 1234567)
Ejemplo: 24-01-3420-1234567
Nota: Cada archivo ZIP/RAR se procesa independientemente en su propio subdirectorio.
Respuesta exitosa:
{
"message": "Pedimentos creados exitosamente",
"created_count": 5,
"created_pedimentos": [...],
"documents_created": 15,
"processed_files": 3,
"summary": "Procesados 3 archivo(s): 5 pedimento(s) creado(s), 15 documento(s) asociado(s)",
"failed_files": [],
"errors": []
}
"""
print(request.data)
# Validar datos requeridos
contribuyente = request.data.get('contribuyente')
archivos = request.FILES.getlist('archivos')
if not contribuyente:
return Response(
{"error": "Se requiere el campo 'contribuyente'"},
status=status.HTTP_400_BAD_REQUEST
)
if not archivos:
return Response(
{"error": "Se requiere al menos un archivo"},
status=status.HTTP_400_BAD_REQUEST
)
# Validar organización del usuario
if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'):
return Response(
{"error": "Usuario no autenticado o sin organización"},
status=status.HTTP_400_BAD_REQUEST
)
organizacion = request.user.organizacion
# Regex para validar nomenclatura: anio-aduana-patente-pedimento
nomenclatura_pattern = re.compile(r'^(\d{2})-(\d{2,3})-(\d{4})-(\d{7})$')
nomenclatura_pattern_sin_anio = re.compile(r'^(\d{2,3})-(\d{4})-(\d{7})$')
created_pedimentos = []
failed_files = []
errors = []
documents_created = 0
temp_dir = None
# Obtener DocumentType ANTES de la transacción atómica
print("Intentando obtener o crear DocumentType...")
try:
# Primero intentar obtener si ya existe
try:
document_type = DocumentType.objects.get(nombre="Pedimento")
print(f"DocumentType obtenido existente: {document_type.nombre} (ID: {document_type.id})")
except DocumentType.DoesNotExist:
# Si no existe, crear uno nuevo
document_type = DocumentType.objects.create(
nombre="Pedimento",
descripcion="Documento de pedimento"
)
print(f"DocumentType creado nuevo: {document_type.nombre} (ID: {document_type.id})")
except Exception as e:
print(f"Error al obtener/crear DocumentType: {str(e)}")
# Como fallback, intentar obtener cualquier DocumentType existente
try:
document_type = DocumentType.objects.first()
if document_type:
print(f"Usando DocumentType existente como fallback: {document_type.nombre} (ID: {document_type.id})")
else:
print("No hay DocumentType disponible")
return Response(
{"error": "No se pudo configurar el tipo de documento y no hay tipos existentes"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
except Exception as fallback_error:
print(f"Error en fallback: {str(fallback_error)}")
return Response(
{"error": f"Error crítico al configurar tipo de documento: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
try:
print("Iniciando transacción atómica...")
with transaction.atomic():
# Crear directorio temporal
temp_dir = tempfile.mkdtemp()
print(f"Directorio temporal creado: {temp_dir}")
# Procesar cada archivo enviado
for idx, archivo in enumerate(archivos):
archivo_name = archivo.name.lower()
print(f"Procesando archivo {idx + 1}/{len(archivos)}: {archivo_name}")
# Crear subdirectorio para cada archivo usando el nombre del archivo sin extensión
archivo_name_sin_extension = os.path.splitext(archivo.name)[0]
sub_dir = os.path.join(temp_dir, archivo_name_sin_extension)
os.makedirs(sub_dir, exist_ok=True)
print(f"Subdirectorio creado: {sub_dir}")
if archivo_name.endswith('.zip'):
# Manejar archivo ZIP
print("Es un archivo ZIP")
try:
with zipfile.ZipFile(archivo, 'r') as zip_ref:
zip_ref.extractall(sub_dir)
print("Archivo ZIP extraído exitosamente")
except zipfile.BadZipFile as e:
return Response(
{"error": f"Archivo ZIP corrupto o inválido: {archivo.name} - {str(e)}"},
status=status.HTTP_400_BAD_REQUEST
)
except Exception as e:
return Response(
{"error": f"Error al extraer ZIP {archivo.name}: {str(e)}"},
status=status.HTTP_400_BAD_REQUEST
)
elif archivo_name.endswith('.rar'):
# Manejar archivo RAR: guardar el archivo en disco y usar helper con fallbacks
# Guardar el archivo subido en un path temporal dentro del sub_dir
archivo_temp_path = os.path.join(sub_dir, archivo.name)
with open(archivo_temp_path, 'wb') as f:
for chunk in archivo.chunks():
f.write(chunk)
try:
extract_rar_to_dir(archivo_temp_path, sub_dir)
print(f"Archivo RAR {archivo.name} extraído en {sub_dir}")
except Exception as e:
error_msg = str(e)
help_msg = "Instale 'unrar' o 'p7zip' (7z) y asegúrese de que estén en PATH, o instale y configure 'rarfile' con un backend."
return Response(
{"error": f"Error al extraer archivo RAR {archivo.name}: {error_msg}. {help_msg}"},
status=status.HTTP_400_BAD_REQUEST
)
# if not RAR_SUPPORT:
# return Response(
# {"error": "Soporte para archivos RAR no disponible. Instalar rarfile: pip install rarfile"},
# status=status.HTTP_400_BAD_REQUEST
# )
# try:
# with rarfile.RarFile(archivo, 'r') as rar_ref:
# rar_ref.extractall(sub_dir)
# print(f"Archivo RAR {archivo.name} extraído en sub_dir")
# except rarfile.Error as e:
# return Response(
# {"error": f"Error al extraer archivo RAR {archivo.name}: {str(e)}"},
# status=status.HTTP_400_BAD_REQUEST
# )
else:
# Asumir que es un archivo individual
# Crear el archivo en el subdirectorio
archivo_path = os.path.join(sub_dir, archivo.name)
with open(archivo_path, 'wb') as f:
for chunk in archivo.chunks():
f.write(chunk)
print(f"Archivo individual {archivo.name} guardado en sub_dir:", archivo_path)
# Recorrer todos los archivos extraídos o el directorio
print("Iniciando recorrido de archivos...")
for root, dirs, files in os.walk(temp_dir):
print(f"Revisando directorio: {root}")
print(f"Archivos encontrados: {files}")
for file_name in files:
print(f"Procesando archivo: {file_name}")
file_path = os.path.join(root, file_name)
# Obtener la ruta relativa para determinar la estructura de carpetas
relative_path = os.path.relpath(file_path, temp_dir)
print(f"Ruta relativa: {relative_path}")
# Determinar si el archivo está en una carpeta que sigue la nomenclatura
folder_name = None
if os.path.dirname(relative_path):
# El archivo está dentro de una carpeta
folder_parts = relative_path.split(os.sep)
folder_name = folder_parts[0] # Primera carpeta (nombre del archivo ZIP/RAR sin extensión)
else:
# El archivo está en la raíz, usar el nombre del archivo sin extensión
folder_name = os.path.splitext(file_name)[0]
print(f"Folder name para validación: {folder_name}")
# Validar nomenclatura
match = nomenclatura_pattern.match(folder_name)
match_sin_anio = nomenclatura_pattern_sin_anio.match(folder_name)
if not match and not match_sin_anio:
print(f"Nomenclatura inválida: {folder_name}")
# Determinar el archivo original basado en el subdirectorio
archivo_original = folder_name + ('.zip' if any(f.endswith('.zip') for f in [a.name for a in archivos]) else '.rar')
failed_files.append({
"file": relative_path,
"archivo_original": archivo_original,
"error": f"Nomenclatura inválida: {folder_name}. Esperado: anio-aduana-patente-pedimento"
})
continue
if match:
print(f"Nomenclatura válida: {folder_name}")
anio, aduana, patente, pedimento_num = match.groups()
print(f"Extraído - Año: {anio}, Aduana: {aduana}, Patente: {patente}, Pedimento: {pedimento_num}")
# Formato original: anio-aduana-patente-pedimento
# Crear fecha_pago basada en el año
try:
# Convertir año de 2 dígitos a 4 dígitos
anio_completo = 2000 + int(anio) if int(anio) < 50 else 1900 + int(anio)
fecha_pago = datetime(anio_completo, 1, 1).date()
print(f"Fecha de pago calculada: {fecha_pago}")
except ValueError:
archivo_original = folder_name + ('.zip' if any(f.endswith('.zip') for f in [a.name for a in archivos]) else '.rar')
failed_files.append({
"file": relative_path,
"archivo_original": archivo_original,
"error": f"Año inválido: {anio}"
})
continue
elif match_sin_anio:
print(f"Nomenclatura válida sin año: {folder_name}")
# Formato sin año: aduana-patente-pedimento
aduana, patente, pedimento_num = match_sin_anio.groups()
print(f"Extraído - Aduana: {aduana}, Patente: {patente}, Pedimento: {pedimento_num}")
# Obtener el primer dígito del pedimento
primer_digito_pedimento = int(pedimento_num[0]) if pedimento_num else 0
# Usar año actual para fecha_pago y ajustar según el dígito del pedimento
año_actual = datetime.now().year
# Crear año con el dígito del pedimento (reemplazando el último dígito)
año_con_digito = int(str(año_actual)[:-1] + str(primer_digito_pedimento))
# Aplicar lógica de comparación
if año_con_digito <= año_actual:
# Si el año con dígito es menor o igual al año actual
año_final = año_con_digito
else:
# Si el año con dígito es mayor al año actual, restar 10
año_final = año_con_digito - 10
# Tomar los últimos 2 dígitos del año final
anio = año_final % 100
# Crear fecha de pago (primer día del año)
fecha_pago = datetime(año_final , 1, 1).date()
print(f"Fecha de pago (año actual) calculada: {fecha_pago}")
# Generar pedimento_app
pedimento_app = f"{anio}-{aduana.zfill(2)}-{patente}-{pedimento_num}"
print(f"Pedimento_app generado: {pedimento_app}")
print(f"Buscando pedimento existente con pedimento_app: {pedimento_app} y organización ID: {organizacion.id}")
# Verificar si el pedimento ya existe
existing_pedimento = Pedimento.objects.filter(
pedimento_app=pedimento_app,
# organizacion=organizacion
).first()
print(f"Pedimento existente: {existing_pedimento is not None}")
if not existing_pedimento:
print("📝 Pedimento no existe, creando nuevo...")
# Crear nuevo pedimento
try:
print("🔄 Iniciando creación de pedimento...")
# Obtener o crear el importador
print(f"🏢 Buscando/creando importador con RFC: {contribuyente}")
importador, created = Importador.objects.get_or_create(
rfc=contribuyente,
defaults={
'nombre': f"Importador {contribuyente}",
'organizacion': organizacion
}
)
if created:
print(f"✅ Importador creado: {importador.rfc} - {importador.nombre}")
else:
print(f"♻️ Importador existente: {importador.rfc} - {importador.nombre}")
pedimento = Pedimento.objects.create(
organizacion=organizacion,
contribuyente=importador,
pedimento=int(pedimento_num),
aduana=int(aduana),
patente=int(patente),
fecha_pago=fecha_pago,
pedimento_app=pedimento_app,
agente_aduanal=f"Agente {patente}", # Valor por defecto
clave_pedimento="A1" # Valor por defecto
)
print(f"✅ Pedimento creado exitosamente: ID {pedimento.id}, pedimento_app: {pedimento_app}")
created_pedimentos.append({
"id": str(pedimento.id),
"pedimento_app": pedimento_app,
"contribuyente": importador.rfc,
"contribuyente_nombre": importador.nombre
})
except Exception as e:
print(f"❌ Error al crear pedimento: {str(e)}")
archivo_original = folder_name + ('.zip' if any(f.endswith('.zip') for f in [a.name for a in archivos]) else '.rar')
failed_files.append({
"file": relative_path,
"archivo_original": archivo_original,
"error": f"Error al crear pedimento: {str(e)}"
})
continue
else:
print(f"♻️ Usando pedimento existente: ID {existing_pedimento.id}")
# Usar pedimento existente
pedimento = existing_pedimento
print(f"🔄 Iniciando creación de documento para pedimento ID: {pedimento.id}")
# Crear documento asociado al pedimento
try:
print("📖 Leyendo archivo desde directorio temporal...")
# Leer el archivo desde el directorio temporal
with open(file_path, 'rb') as f:
file_content = f.read()
print(f"📄 Archivo leído: {len(file_content)} bytes")
# Crear ContentFile que Django puede manejar correctamente
django_file = ContentFile(file_content, name=file_name)
# # Verificar si el documento ya existe para este pedimento y archivo
# print("🔍 Verificando existencia previa del documento...")
# # Reemplazar múltiples caracteres
# normalized_file_name = file_name.replace(" ", "_")
# file_name_without_extension = normalized_file_name.rsplit('.', 1)[0]
# extension_file = os.path.splitext(normalized_file_name)[1].lower().lstrip('.')
# existing_document = Document.objects.filter(
# pedimento_id=pedimento.id,
# archivo__contains=file_name_without_extension,
# extension=extension_file
# ).first()
# if existing_document:
# print(f"Documento existente encontrado, omitiendo creación: ID {existing_document.id}")
# continue
print(f"Creando documento para archivo: {file_name}")
# Crear documento - Django automáticamente guardará el archivo en media/documents/
document = Document.objects.create(
organizacion=organizacion,
pedimento_id=pedimento.id,
document_type=document_type,
fuente_id=4, # Fuente: Carga Plataforma
archivo=django_file,
size=len(file_content),
extension=os.path.splitext(file_name)[1].lower().lstrip('.')
)
print(f"Documento creado exitosamente: {document.id}")
documents_created += 1
print(f"📊 Total documentos creados hasta ahora: {documents_created}")
except Exception as e:
print(f"❌ Error al crear documento: {str(e)}")
archivo_original = folder_name + ('.zip' if any(f.endswith('.zip') for f in [a.name for a in archivos]) else '.rar')
failed_files.append({
"file": relative_path,
"archivo_original": archivo_original,
"error": f"Error al crear documento: {str(e)}"
})
continue
print(f"🏁 Procesamiento completado. Archivos procesados en este directorio.")
except Exception as e:
return Response(
{"error": f"Error durante el procesamiento: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
finally:
# Limpiar directorio temporal
if temp_dir and os.path.exists(temp_dir):
shutil.rmtree(temp_dir)
# Preparar respuesta
response_data = {
"created_count": len(created_pedimentos),
"created_pedimentos": created_pedimentos,
"documents_created": documents_created,
"failed_files": failed_files,
"processed_files": len(archivos),
"summary": f"Procesados {len(archivos)} archivo(s): {len(created_pedimentos)} pedimento(s) creado(s), {documents_created} documento(s) asociado(s)"
}
if failed_files:
response_data.update({
"message": "Procesamiento completado con algunos errores",
"errors": [item["error"] for item in failed_files]
})
response_status = status.HTTP_207_MULTI_STATUS
else:
response_data["message"] = "Pedimentos creados exitosamente"
response_status = status.HTTP_201_CREATED
return Response(response_data, status=response_status)
my_tags = ['Pedimentos'] my_tags = ['Pedimentos']
class PartidaViewSet(viewsets.ModelViewSet): class PartidaViewSet(viewsets.ModelViewSet):

View File

@@ -4,12 +4,21 @@ from rest_framework.routers import DefaultRouter
# import necessary viewsets # import necessary viewsets
# from .views import YourViewSet # Import your viewsets here # 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 # Create a router and register your viewsets with it
router = DefaultRouter() router = DefaultRouter()
# Register your viewsets with the router here # Register your viewsets with the router he -fre
# Example: # Example:
# from .views import MyViewSet # from .views import MyViewSet
# router.register(r'myviewset', MyViewSet, basename='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('documents/descargar/<uuid:pk>/', ProtectedDocumentDownloadView.as_view(), name='descargar-documento'),
path('fuente/', GetFuenteView.as_view(), name='get-fuente'), path('fuente/', GetFuenteView.as_view(), name='get-fuente'),
path('document-type/', DocumentTypeView.as_view(), name='document-type-list-create'), 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)), path('', include(router.urls)),
] ]

View File

@@ -13,6 +13,7 @@ from rest_framework.exceptions import ValidationError
from .serializers import DocumentSerializer, FuenteSerializer, DocumentTypeSerializer from .serializers import DocumentSerializer, FuenteSerializer, DocumentTypeSerializer
from .models import Document, Fuente, DocumentType from .models import Document, Fuente, DocumentType
from ..customs.models import Pedimento
from api.organization.models import UsoAlmacenamiento from api.organization.models import UsoAlmacenamiento
from io import BytesIO from io import BytesIO
import zipfile import zipfile
@@ -32,6 +33,9 @@ from core.permissions import (
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
import os
from django.core.files.storage import default_storage
from mixins.filtrado_organizacion import DocumentosFiltradosMixin from mixins.filtrado_organizacion import DocumentosFiltradosMixin
class CustomPagination(PageNumberPagination): class CustomPagination(PageNumberPagination):
@@ -59,7 +63,8 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
pagination_class = CustomPagination pagination_class = CustomPagination
serializer_class = DocumentSerializer serializer_class = DocumentSerializer
# Habilitar filtro por pedimento (UUID) y pedimento_numero (campo pedimento del modelo relacionado) # 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> # Puedes filtrar por pedimento usando: /api/record/documents/?pedimento=<id> o /api/record/documents/?pedimento__pedimento=<numero>
# Ejemplo: /api/record/documents/?pedimento_numero=12345678 # Ejemplo: /api/record/documents/?pedimento_numero=12345678
@@ -67,6 +72,33 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
def get_queryset(self): def get_queryset(self):
queryset = self.get_queryset_filtrado_por_organizacion() 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') pedimento_numero = self.request.query_params.get('pedimento_numero')
if pedimento_numero: if pedimento_numero:
queryset = queryset.filter(pedimento__pedimento_app=pedimento_numero) queryset = queryset.filter(pedimento__pedimento_app=pedimento_numero)
@@ -530,7 +562,6 @@ class ProtectedDocumentDownloadView(APIView, DocumentosFiltradosMixin):
def get(self, request, pk): def get(self, request, pk):
if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'): if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'):
raise Http404("Usuario no autenticado") raise Http404("Usuario no autenticado")
try: try:
doc = Document.objects.get(pk=pk) doc = Document.objects.get(pk=pk)
@@ -617,4 +648,192 @@ class DocumentTypeView(APIView):
if not queryset.exists(): if not queryset.exists():
return Response({"detail": "No hay tipos de documento disponibles."}, status=404) return Response({"detail": "No hay tipos de documento disponibles."}, status=404)
serializer = self.serializer_class(queryset, many=True) serializer = self.serializer_class(queryset, many=True)
return Response(serializer.data, status=200) 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 celery import shared_task
from api.organization.models import Organizacion
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.utils import timezone from django.utils import timezone
from api.reports.models import ReportDocument 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)) pedimento_ids = list(pedimentos_qs.values_list('id', flat=True))
rfcs_raw = list(pedimentos_qs.values_list('agente_aduanal', flat=True))
# inicializar totales # inicializar totales
pedimentos_completos = 0 pedimentos_completos = 0
total_documentos = 0 total_documentos = 0
documentos_sin_descargar = 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 # Para cada pedimento, verificar si está completo
for pedimento in pedimentos_qs: for pedimento in pedimentos_qs:
# Contar documentos de este pedimento # Contar documentos de este pedimento
@@ -216,12 +244,15 @@ def generate_report_control_pedimento(report_id):
# SECCIÓN DE TOTALES # SECCIÓN DE TOTALES
writer.writerow(['RESUMEN DEL REPORTE - CONTROL DE PEDIMENTOS']) writer.writerow(['RESUMEN DEL REPORTE - CONTROL DE PEDIMENTOS'])
writer.writerow(['OOGANIZACION:', nombre_organizacion])
writer.writerow([]) writer.writerow([])
writer.writerow(['TOTAL DE EXPEDIENTES:', pedimentos_total]) writer.writerow(['TOTAL DE EXPEDIENTES:', pedimentos_total])
writer.writerow(['TOTAL DE EXPEDIENTES COMPLETOS:', pedimentos_completos]) writer.writerow(['TOTAL DE EXPEDIENTES COMPLETOS:', pedimentos_completos])
writer.writerow(['TOTAL DE DOCUMENTOS:', total_documentos]) writer.writerow(['TOTAL DE DOCUMENTOS:', total_documentos])
writer.writerow(['DOCUMENTOS SIN DESCARGAR:', documentos_sin_descargar]) writer.writerow(['DOCUMENTOS SIN DESCARGAR:', documentos_sin_descargar])
writer.writerow(['PORCENTAJE DE DOCUMENTOS FALTANTES (%):', f"{porcentaje_faltantes:.2f}%"]) 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([])
writer.writerow([]) writer.writerow([])

View File

@@ -114,7 +114,7 @@ class ExportDataStageView(APIView):
# Constantes para partición # Constantes para partición
# MAX_RECORDS_PER_FILE = 100 # Límite seguro por archivo # 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): 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) 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) 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 export_type == 'excel':
if total_estimated_records > self.MAX_RECORDS_PER_FILE: # Siempre usar el método particionado inteligente para Excel
if export_type == 'excel': return self.export_datastage_multiple_partitioned_excel(request, models_data, global_filters, related_keys)
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)
else: else:
if export_type == 'excel': # Para CSV, podemos mantener la lógica actual o mejorarla
return self.export_datastage_multiple_to_excel(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: else:
return self.export_datastage_multiple_to_csv(request, models_data, global_filters, related_keys) return self.export_datastage_multiple_to_csv(request, models_data, global_filters, related_keys)
@@ -281,11 +280,15 @@ class ExportDataStageView(APIView):
return response return response
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):
"""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: try:
zip_buffer = io.BytesIO() zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file: 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: for model_data in models_data:
model_name = model_data.get('model') model_name = model_data.get('model')
@@ -298,17 +301,17 @@ class ExportDataStageView(APIView):
model = apps.get_model('datastage', model_name) model = apps.get_model('datastage', model_name)
filters = self.apply_related_filters(global_filters, model, related_keys, request.user) filters = self.apply_related_filters(global_filters, model, related_keys, request.user)
# Si hay filtros, aplicarlos; si no, obtener todos los registros
if filters: if filters:
queryset = model.objects.filter(**filters).values(*fields) queryset = model.objects.filter(**filters).values(*fields)
else: else:
queryset = model.objects.none() # No obtener nada si no hay filtros queryset = model.objects.none()
total_records = queryset.count() total_records = queryset.count()
if total_records == 0: if total_records == 0:
continue continue
# Si el modelo necesita particionarse (más de MAX_RECORDS_PER_FILE)
if total_records > self.MAX_RECORDS_PER_FILE: if total_records > self.MAX_RECORDS_PER_FILE:
from django.core.paginator import Paginator from django.core.paginator import Paginator
paginator = Paginator(queryset, self.MAX_RECORDS_PER_FILE) paginator = Paginator(queryset, self.MAX_RECORDS_PER_FILE)
@@ -316,29 +319,62 @@ class ExportDataStageView(APIView):
for page_num in paginator.page_range: for page_num in paginator.page_range:
page = paginator.page(page_num) page = paginator.page(page_num)
wb = openpyxl.Workbook() # Verificar si necesitamos crear nuevo archivo
ws = wb.active # 1. Si no hay archivo actual
ws.title = f"Parte_{page_num}"[:31] # 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) ws.append(fields)
# Escribir datos
for row in page.object_list: for row in page.object_list:
row_values = [self.safe_excel_value(row[field]) for field in fields] row_values = [self.safe_excel_value(row[field]) for field in fields]
ws.append(row_values) ws.append(row_values)
# Guardar parte en ZIP current_file_records_count += len(page.object_list)
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())
else: else:
wb = openpyxl.Workbook() # Modelo pequeño (≤ MAX_RECORDS_PER_FILE)
ws = wb.active # Verificar si necesitamos nuevo archivo
ws.title = "Datos"[:31] 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) ws.append(fields)
# Escribir datos # Escribir datos
@@ -346,25 +382,22 @@ class ExportDataStageView(APIView):
row_values = [self.safe_excel_value(row[field]) for field in fields] row_values = [self.safe_excel_value(row[field]) for field in fields]
ws.append(row_values) ws.append(row_values)
part_buffer = io.BytesIO() current_file_records_count += total_records
wb.save(part_buffer)
part_buffer.seek(0)
filename = f"{model_name}.xlsx" except LookupError:
zip_file.writestr(filename, part_buffer.getvalue())
except LookupError as e:
continue 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_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-Disposition'] = 'attachment; filename="datastage_reports.zip"'
response['Content-Length'] = len(zip_content)
return response return response
except Exception as e: except Exception as e:
@@ -488,7 +521,7 @@ class ExportDataStageView(APIView):
except Exception as e: except Exception as e:
return Response({'error': f'Error en exportación CSV particionada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) return Response({'error': f'Error en exportación CSV particionada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
def export_single_model_partitioned(self, request, model_name, fields, filters, total_records): def export_single_model_partitioned(self, request, model_name, fields, filters, total_records):
"""Exporta un solo modelo particionado a ZIP""" """Exporta un solo modelo particionado a ZIP"""
try: try:
@@ -578,62 +611,50 @@ 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 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 = { related_keys = {
'patentes': set(), 'patentes': set(),
'pedimentos': set(), 'pedimentos': set(),
'datastage_ids': set() 'datastage_ids': set()
} }
# Si no hay filtros globales, retornar vacío (no hay relación) # Si no hay filtros, retornar vacío
if not any(global_filters.values()): if not any(v for v in global_filters.values() if v not in [None, '']):
return {} return {}
all_records_with_filters = [] all_records_with_filters = []
# Buscar en TODOS los modelos que puedan tener los campos de filtro
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_fields = [f.name for f in model._meta.get_fields()]
# Construir filtros EXACTOS con TODOS los campos disponibles # ¡USAR LA MISMA FUNCIÓN QUE EN MODO SINGULAR!
filters = {} filters = self.apply_global_filters_to_model(global_filters, model, user)
has_any_filter = False
if 'organizacion' in model_fields and global_filters.get('organizacion'): if filters:
filters['organizacion'] = global_filters['organizacion'] # EJECUTAR CONSULTA - IDÉNTICO A MODO SINGULAR
has_any_filter = True queryset = model.objects.filter(**filters)
total = queryset.count()
if 'patente' in model_fields and global_filters.get('patente'):
filters['patente'] = global_filters['patente'] # VERIFICACIÓN ESPECIAL PARA RFC
has_any_filter = True if 'rfc' in filters:
rfc_value = filters['rfc']
if 'pedimento' in model_fields and global_filters.get('pedimento'): # Doble verificación: contar registros con ese RFC exacto
filters['pedimento'] = global_filters['pedimento'] rfc_exact_count = queryset.filter(rfc=rfc_value).count()
has_any_filter = True
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()
all_records_with_filters.extend(list(records))
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
@@ -648,95 +669,214 @@ class ExportDataStageView(APIView):
if record.get('datastage_id'): if record.get('datastage_id'):
related_keys['datastage_ids'].add(record['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 {k: list(v) for k, v in related_keys.items() if v}
return related_keys
def apply_global_filters_to_model(self, global_filters, model, user): 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 = {} filters = {}
model_fields = [f.name for f in model._meta.get_fields()] model_fields = [f.name for f in model._meta.get_fields()]
if 'organizacion' in model_fields and global_filters.get('organizacion'): # ORGANIZACIÓN - Manejar como UUID
filters['organizacion'] = global_filters['organizacion'] org_value = global_filters.get('organizacion')
if org_value and org_value != '' and 'organizacion' in model_fields:
field = model._meta.get_field('organizacion')
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
if 'patente' in model_fields and global_filters.get('patente'): # 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'] 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'] filters['pedimento'] = global_filters['pedimento']
if 'rfc' in model_fields and global_filters.get('rfc'): # FECHAS
filters['rfc'] = global_filters['rfc']
if 'fecha_pago_real' in model_fields: if 'fecha_pago_real' in model_fields:
if global_filters.get('fecha_pago_desde'): if global_filters.get('fecha_pago_desde'):
filters['fecha_pago_real__gte'] = global_filters['fecha_pago_desde'] filters['fecha_pago_real__gte'] = global_filters['fecha_pago_desde']
if global_filters.get('fecha_pago_hasta'): if global_filters.get('fecha_pago_hasta'):
filters['fecha_pago_real__lte'] = global_filters['fecha_pago_hasta'] filters['fecha_pago_real__lte'] = global_filters['fecha_pago_hasta']
return filters return filters
def apply_related_filters(self, global_filters, model, related_keys, user): 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 = {} 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
# 🔥 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)
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'] 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 'fecha_pago_real' in model_fields:
if global_filters.get('fecha_pago_desde'): if global_filters.get('fecha_pago_desde'):
filters['fecha_pago_real__gte'] = global_filters['fecha_pago_desde'] filters['fecha_pago_real__gte'] = global_filters['fecha_pago_desde']
if global_filters.get('fecha_pago_hasta'): if global_filters.get('fecha_pago_hasta'):
filters['fecha_pago_real__lte'] = global_filters['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 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): class ExportModelView(APIView):
my_tags = ['Reportes'] my_tags = ['Reportes']
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]