diff --git a/api/customs/models.py b/api/customs/models.py index d37612e..748516b 100644 --- a/api/customs/models.py +++ b/api/customs/models.py @@ -34,6 +34,7 @@ class Pedimento(models.Model): fecha_pago = models.DateField(help_text="Fecha de pago del pedimento", blank=True, null=True) alerta = models.BooleanField(default=False, help_text="Indica si el pedimento tiene una alerta asociada") + consultar_vucem = models.BooleanField(default=False, help_text="Solo pedimentos originados desde datastage deben consultar VUCEM automáticamente") contribuyente = models.ForeignKey('Importador', on_delete=models.CASCADE, related_name='pedimentos', help_text="Contribuyente asociado al pedimento", blank=True, null=True) agente_aduanal = models.CharField(max_length=100, blank=True, null=True, help_text="RFC del agente aduanal") diff --git a/api/customs/signals/procesamiento.py b/api/customs/signals/procesamiento.py index 1b1e163..9578612 100644 --- a/api/customs/signals/procesamiento.py +++ b/api/customs/signals/procesamiento.py @@ -20,12 +20,15 @@ from api.customs.tasks.microservice import ( @receiver(post_save, sender=Pedimento) def trigger_celery_task_on_create(sender, instance, created, **kwargs): - + if not created: import logging logger = logging.getLogger('api.customs.async_operations') logger.info("NO es creación de pedimento, no se crea procesamiento.") return + + if not instance.consultar_vucem: + return def crear_procesamiento(): import logging diff --git a/api/customs/tasks/bulk_upload.py b/api/customs/tasks/bulk_upload.py index f9c5809..b931e3c 100644 --- a/api/customs/tasks/bulk_upload.py +++ b/api/customs/tasks/bulk_upload.py @@ -1,5 +1,4 @@ from celery import shared_task -from django.core.files.base import ContentFile from django.utils import timezone import os import zipfile @@ -615,66 +614,66 @@ def bulk_upload_record_task(self, organizacion_id, parametros, archivo_paths): tiene_nomenclatura_especial = True info_extraida = procesar_archivo_m_con_nomenclatura(file_content, existing_pedimento) - django_file = ContentFile(file_content, name=file_name) - # Buscar documento existente existing_documents = Document.objects.filter( pedimento_id=existing_pedimento.id, organizacion=organizacion ) - + existing_document = None for doc in existing_documents: if is_same_document(doc, file_name): existing_document = doc break - + if existing_document: - # Actualizar documento existente - # try: - # if existing_document.archivo and os.path.exists(existing_document.archivo.path): - # os.remove(existing_document.archivo.path) - # except (ValueError, OSError): - # pass - - # existing_document.archivo = django_file - # existing_document.size = len(file_content) - # existing_document.extension = extension - # existing_document.updated_at = timezone.now() - # existing_document.save() - - # doc = Document.objects.get(id=existing_document.id) - # doc.archivo.delete(save=False) # Eliminar el archivo anterior - # doc.delete() # Eliminar el registro para crear uno nuevo (evita problemas con archivos en Django) - updated_pedimentos.append({ "id": str(existing_pedimento.id), "pedimento_app": existing_pedimento.pedimento_app, - "accion": "Documento actualizado", + "accion": "Documento ya existente, omitido", "documento": file_name }) - - documents_created += 1 else: - # Crear nuevo documento + # Crear registro sin archivo primero document = Document.objects.create( organizacion=organizacion, pedimento_id=existing_pedimento.id, document_type=document_type, fuente_id=fuente.id, - archivo=django_file, size=len(file_content), extension=os.path.splitext(file_name)[1].lower().lstrip('.') ) - updated_pedimentos.append({ - "id": str(existing_pedimento.id), - "pedimento_app": existing_pedimento.pedimento_app, - "accion": "Documento creado", - "documento": file_name - }) + from api.utils.storage_service import storage_service + ruta = storage_service.save_document_from_path( + file_path=file_path, + file_name=file_name, + organizacion_id=organizacion.id, + pedimento_app=existing_pedimento.pedimento_app, + metadata={ + 'pedimento_id': str(existing_pedimento.id), + 'document_id': str(document.id), + 'source': 'bulk_upload_async' + } + ) - documents_created += 1 + if ruta: + document.archivo = ruta + document.save() + documents_created += 1 + updated_pedimentos.append({ + "id": str(existing_pedimento.id), + "pedimento_app": existing_pedimento.pedimento_app, + "accion": "Documento creado", + "documento": file_name + }) + else: + document.delete() + failed_records.append({ + "file": relative_path, + "archivo_original": folder_name + '.zip', + "error": f"Error al guardar {file_name} en almacenamiento" + }) except Exception as e: failed_records.append({ diff --git a/api/customs/tasks/microservice_v2.py b/api/customs/tasks/microservice_v2.py index 95b4369..c094c8c 100644 --- a/api/customs/tasks/microservice_v2.py +++ b/api/customs/tasks/microservice_v2.py @@ -563,11 +563,14 @@ def process_all_organizations(): """ Envía una tarea por organización activa a la cola org_processing. """ - active_orgs = Organizacion.objects.filter(is_active=True, is_verified=True) - + active_orgs = Organizacion.objects.filter( + is_active=True, + is_verified=True, + apply_auto_download=True, + ) for org in active_orgs: process_organization_batch.apply_async( - args=[org.id], + args=[str(org.id)], queue='org_processing' ) return f"Dispatched {active_orgs.count()} organizations" diff --git a/api/datastage/tasks.py b/api/datastage/tasks.py index e95f814..c1fd8b3 100644 --- a/api/datastage/tasks.py +++ b/api/datastage/tasks.py @@ -297,6 +297,7 @@ def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name): "importe_pedimento": data.get('importe_pedimento', 0.0), "existe_expediente": data.get('existe_expediente', False), "remesas": data.get('remesas', False), + "consultar_vucem": True, } try: Pedimento.objects.create(**pedimento_data) diff --git a/api/organization/models.py b/api/organization/models.py index caa49c4..9f9b099 100644 --- a/api/organization/models.py +++ b/api/organization/models.py @@ -42,6 +42,7 @@ class Organizacion(models.Model): is_active = models.BooleanField(default=True) is_verified = models.BooleanField(default=False) + apply_auto_download = models.BooleanField(default=False) inicio = models.DateField(null=True, blank=True) vencimiento = models.DateField(null=True, blank=True) diff --git a/api/record/views.py b/api/record/views.py index 92e2cb6..02bfedb 100644 --- a/api/record/views.py +++ b/api/record/views.py @@ -1278,15 +1278,21 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin): status=status.HTTP_403_FORBIDDEN ) - # Usar tipo de documento por defecto siempre - document_type, created = DocumentType.objects.get_or_create( - nombre="Documento General", - defaults={'descripcion': "Documento general sin tipo específico"} - ) - if created: - print(f"✅ DocumentType creado: {document_type.nombre} (ID: {document_type.id})") + # Usar tipo de documento indicado o "Documento General" por defecto + document_type_id_param = request.data.get('document_type_id') + if document_type_id_param: + try: + document_type = DocumentType.objects.get(id=int(document_type_id_param)) + except (DocumentType.DoesNotExist, ValueError): + return Response( + {"error": f"Tipo de documento con ID '{document_type_id_param}' no encontrado"}, + status=status.HTTP_400_BAD_REQUEST + ) else: - print(f"♻️ DocumentType existente: {document_type.nombre} (ID: {document_type.id})") + document_type, _ = DocumentType.objects.get_or_create( + nombre="Documento General", + defaults={'descripcion': "Documento general sin tipo específico"} + ) uploaded_documents = [] failed_files = [] @@ -1371,6 +1377,7 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin): existing_doc.archivo = ruta existing_doc.size = file.size existing_doc.extension = extension + existing_doc.document_type = document_type existing_doc.save() else: raise Exception(f"Error al guardar archivo: {file.name}") @@ -1406,7 +1413,7 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin): "filename": file.name, "size": file.size, "extension": extension, - "document_type": document_type.nombre + "document_type": document.document_type.nombre if document.document_type else None }) except Exception as e: @@ -1750,6 +1757,265 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin): return Response(response_data, status=response_status) + @action(detail=False, methods=['post'], url_path='create-vu-record', parser_classes=[MultiPartParser]) + def create_vu_record(self, request): + """ + Crea un registro (Partida/Cove/EDocument) en su tabla correspondiente + y sube sus archivos con la nomenclatura VU. + + FormData: + - pedimento_id : UUID del pedimento + - tab_seccion : 'partida' | 'cove' | 'edoc' + - numero : número del registro a crear + - files : archivos (nombre con flag de sección: .xml.general, .pdf.acuse, etc.) + """ + pedimento_id = request.data.get('pedimento_id') + tab_seccion = request.data.get('tab_seccion') + numero = request.data.get('numero', '').strip() + files = request.FILES.getlist('files') + + if not pedimento_id: + return Response({"error": "Se requiere 'pedimento_id'"}, status=status.HTTP_400_BAD_REQUEST) + if tab_seccion not in ('partida', 'cove', 'edoc'): + return Response({"error": "tab_seccion debe ser 'partida', 'cove' o 'edoc'"}, status=status.HTTP_400_BAD_REQUEST) + if not numero: + return Response({"error": "Se requiere 'numero'"}, status=status.HTTP_400_BAD_REQUEST) + if not files: + return Response({"error": "Se requiere al menos un archivo"}, status=status.HTTP_400_BAD_REQUEST) + if not request.user.is_authenticated: + return Response({"error": "Usuario no autenticado"}, status=status.HTTP_401_UNAUTHORIZED) + + from api.customs.models import Pedimento as PedimentoModel, Partida, Cove, EDocument + try: + pedimento = PedimentoModel.objects.get(id=pedimento_id) + except PedimentoModel.DoesNotExist: + return Response({"error": "Pedimento no encontrado"}, status=status.HTTP_404_NOT_FOUND) + + organizacion = pedimento.organizacion + if not request.user.is_superuser: + if not hasattr(request.user, 'organizacion') or request.user.organizacion != organizacion: + return Response({"error": "Sin permisos para este pedimento"}, status=status.HTTP_403_FORBIDDEN) + + # Validar número entero para partida + numero_int = None + if tab_seccion == 'partida': + try: + numero_int = int(numero) + except ValueError: + return Response({"error": "El número de partida debe ser un entero"}, status=status.HTTP_400_BAD_REQUEST) + + uploaded_documents = [] + failed_files = [] + errors = [] + total_space_used = 0 + expediente_obj = None + + try: + with transaction.atomic(): + uso = UsoAlmacenamiento.objects.select_for_update().get_or_create( + organizacion=organizacion, + defaults={'espacio_utilizado': 0} + )[0] + + max_bytes = organizacion.licencia.almacenamiento * 1024 ** 3 + total_files_size = sum(f.size for f in files) + if uso.espacio_utilizado + total_files_size > max_bytes: + espacio_faltante = (uso.espacio_utilizado + total_files_size) - max_bytes + return Response({ + "error": "Espacio de almacenamiento insuficiente", + "espacio_faltante_gb": round(espacio_faltante / (1024 ** 3), 2), + }, status=status.HTTP_400_BAD_REQUEST) + + # Verificar unicidad y crear registro + if tab_seccion == 'partida': + if Partida.objects.filter(pedimento=pedimento, numero_partida=numero_int).exists(): + return Response( + {"error": f"La partida {numero} ya existe para este pedimento"}, + status=status.HTTP_409_CONFLICT + ) + expediente_obj = Partida.objects.create( + pedimento=pedimento, + organizacion=organizacion, + numero_partida=numero_int + ) + elif tab_seccion == 'cove': + if Cove.objects.filter(pedimento=pedimento, numero_cove=numero).exists(): + return Response( + {"error": f"El COVE {numero} ya existe para este pedimento"}, + status=status.HTTP_409_CONFLICT + ) + expediente_obj = Cove.objects.create( + pedimento=pedimento, + organizacion=organizacion, + numero_cove=numero + ) + elif tab_seccion == 'edoc': + if EDocument.objects.filter(pedimento=pedimento, numero_edocument=numero).exists(): + return Response( + {"error": f"El EDocument {numero} ya existe para este pedimento"}, + status=status.HTTP_409_CONFLICT + ) + expediente_obj = EDocument.objects.create( + pedimento=pedimento, + organizacion=organizacion, + numero_edocument=numero + ) + + espacio_usado_temp = uso.espacio_utilizado + uploaded_secciones = set() + + for file in files: + try: + if not file.name: + failed_files.append("archivo_sin_nombre") + errors.append("Archivo sin nombre detectado") + continue + + filename = file.name + if '.' in filename: + base = '.'.join(filename.split('.')[:-1]) + secciones = filename.split('.')[-1] + else: + base = filename + secciones = '' + + file.name = base + extension = file.name.split('.')[-1].lower() if '.' in file.name else '' + + if tab_seccion == 'partida': + nuevo_nombre = f"vu_PT_{pedimento.pedimento_app}_{numero}.{extension}" + document_type, _ = DocumentType.objects.get_or_create( + nombre="Pedimento Partida", + defaults={'descripcion': "Tag para saber que el archivo guarda una partida"} + ) + elif tab_seccion == 'cove': + if secciones == 'acuse': + nuevo_nombre = f"vu_AC_COVE_{pedimento.pedimento_app}_{numero}.{extension}" + document_type, _ = DocumentType.objects.get_or_create( + nombre="Acuse Cove", + defaults={'descripcion': "Tag para saber que el archivo guarda un acuse de cove"} + ) + else: + nuevo_nombre = f"vu_COVE_{pedimento.pedimento_app}_{numero}.{extension}" + document_type, _ = DocumentType.objects.get_or_create( + nombre="Cove", + defaults={'descripcion': "Tag para saber que el archivo guarda un cove"} + ) + elif tab_seccion == 'edoc': + if secciones == 'acuse': + nuevo_nombre = f"vu_AC_{pedimento.pedimento_app}_{numero}.{extension}" + document_type, _ = DocumentType.objects.get_or_create( + nombre="Pedimento Acuse", + defaults={'descripcion': "Tag para saber que el documento es un Acuse"} + ) + else: + nuevo_nombre = f"vu_ED_{pedimento.pedimento_app}_{numero}.{extension}" + document_type, _ = DocumentType.objects.get_or_create( + nombre="Pedimento EDocument", + defaults={'descripcion': "Tag para saber que el documento es un EDocument"} + ) + + file.name = nuevo_nombre + + document = Document.objects.create( + organizacion=organizacion, + pedimento_id=pedimento_id, + document_type=document_type, + size=file.size, + extension=extension + ) + + ruta = storage_service.save_document( + file=file, + organizacion_id=organizacion.id, + pedimento_app=pedimento.pedimento_app, + metadata={'source': 'create_vu_record'} + ) + + if ruta: + document.archivo = ruta + document.save() + else: + document.delete() + raise Exception(f"Error al guardar archivo: {file.name}") + + espacio_usado_temp += file.size + total_space_used += file.size + uploaded_secciones.add(secciones) + + uploaded_documents.append({ + "id": str(document.id), + "filename": file.name, + "size": file.size, + "extension": extension, + "document_type": document_type.nombre + }) + + except Exception as e: + failed_files.append(file.name) + errors.append(f"Error al procesar {file.name}: {str(e)}") + continue + + # Actualizar flags de descarga según secciones subidas exitosamente + if tab_seccion == 'partida': + if uploaded_secciones: + expediente_obj.descargado = True + expediente_obj.save(update_fields=['descargado']) + elif tab_seccion == 'cove': + update_fields = [] + if 'general' in uploaded_secciones: + expediente_obj.cove_descargado = True + update_fields.append('cove_descargado') + if 'acuse' in uploaded_secciones: + expediente_obj.acuse_cove_descargado = True + update_fields.append('acuse_cove_descargado') + if update_fields: + expediente_obj.save(update_fields=update_fields) + elif tab_seccion == 'edoc': + update_fields = [] + if 'general' in uploaded_secciones: + expediente_obj.edocument_descargado = True + update_fields.append('edocument_descargado') + if 'acuse' in uploaded_secciones: + expediente_obj.acuse_descargado = True + update_fields.append('acuse_descargado') + if update_fields: + expediente_obj.save(update_fields=update_fields) + + uso.espacio_utilizado = espacio_usado_temp + uso.save() + + except Exception as e: + return Response( + {"error": f"Error durante el procesamiento: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + space_used_mb = round(total_space_used / (1024 * 1024), 2) + response_data = { + "uploaded_count": len(uploaded_documents), + "uploaded_documents": uploaded_documents, + "space_used_mb": space_used_mb, + "pedimento_id": str(pedimento_id), + "expediente_id": str(expediente_obj.id), + "tab_seccion": tab_seccion, + "numero": numero, + } + + if failed_files: + response_data.update({ + "message": f"Registro creado pero algunos archivos fallaron", + "failed_files": failed_files, + "errors": errors + }) + response_status = status.HTTP_207_MULTI_STATUS + else: + response_data["message"] = f"{tab_seccion.capitalize()} {numero} creado exitosamente" + response_status = status.HTTP_201_CREATED + + return Response(response_data, status=response_status) + + class ProtectedDocumentDownloadView(APIView, DocumentosFiltradosMixin): permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] serializer_class = DocumentSerializer diff --git a/api/reports/tasks/report_document.py b/api/reports/tasks/report_document.py index 7c45fdf..1d762c6 100644 --- a/api/reports/tasks/report_document.py +++ b/api/reports/tasks/report_document.py @@ -3,7 +3,6 @@ import tempfile from api.utils.storage_service import storage_service 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 from api.customs.models import Pedimento, Cove, EDocument, Partida @@ -127,8 +126,8 @@ def generate_report_document(report_id): @shared_task def generate_report_control_pedimento(report_id): + report = None try: - report = ReportDocument.objects.get(id=report_id) report.status = 'processing' report.save(update_fields=['status']) @@ -222,8 +221,9 @@ def generate_report_control_pedimento(report_id): # 4. GENERAR CSV CON DETALLES filename = f"report_{report.id}_{timezone.now().strftime('%Y%m%d%H%M%S')}.csv" - file_path = os.path.join(settings.MEDIA_ROOT, 'reports', filename) - os.makedirs(os.path.dirname(file_path), exist_ok=True) + + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.csv', encoding='utf-8', newline='') as tmp: + tmp_path = tmp.name todas_las_filas = [] @@ -278,7 +278,7 @@ def generate_report_control_pedimento(report_id): todas_las_filas.append(fila) # 5. ESCRIBIR ARCHIVO CSV - with open(file_path, 'w', newline='', encoding='utf-8') as f: + with open(tmp_path, 'w', newline='', encoding='utf-8') as f: writer = csv.writer(f) # SECCIÓN DE TOTALES @@ -308,15 +308,40 @@ def generate_report_control_pedimento(report_id): writer.writerow(fila) - with open(file_path, 'rb') as f: - report.file.save(filename, ContentFile(f.read()), save=True) - - report.status = 'ready' + with open(tmp_path, 'rb') as f: + file_content = f.read() + + uploaded_file = SimpleUploadedFile( + name=filename, + content=file_content, + content_type='text/csv' + ) + + ruta = storage_service.save_report( + file=uploaded_file, + organizacion_id=filters.get('organizacion_id'), + metadata={ + 'report_id': str(report.id), + 'report_type': 'control_pedimento', + 'user_id': str(report.user.id) if report.user else None + } + ) + + os.unlink(tmp_path) + + if ruta: + report.file = ruta + report.status = 'ready' + else: + report.status = 'error' + report.error_message = 'Error al guardar el archivo en storage' + report.finished_at = timezone.now() - report.save(update_fields=['status', 'file', 'finished_at']) - + report.save(update_fields=['status', 'file', 'finished_at', 'error_message']) + except Exception as e: - report.status = 'error' - report.error_message = str(e) - report.finished_at = timezone.now() - report.save(update_fields=['status', 'error_message', 'finished_at']) \ No newline at end of file + if report: + report.status = 'error' + report.error_message = str(e) + report.finished_at = timezone.now() + report.save(update_fields=['status', 'error_message', 'finished_at']) \ No newline at end of file