From f2bf904c84777c973edea730c89ec67520770ccc Mon Sep 17 00:00:00 2001 From: Luis Date: Fri, 6 Mar 2026 12:48:51 -0700 Subject: [PATCH] nuevo enpoint en segundo plano --- .gitignore | 1 + api/customs/tasks/__init__.py | 3 +- api/customs/tasks/bulk_upload.py | 711 +++++++++++++++++++ api/customs/tasks/microservice_v2.py | 24 +- api/customs/views.py | 982 +++++++++++++++++++++++++++ api/tasks/urls.py | 2 + api/tasks/views.py | 54 ++ 7 files changed, 1773 insertions(+), 4 deletions(-) create mode 100644 api/customs/tasks/bulk_upload.py diff --git a/.gitignore b/.gitignore index e9e644d..fd402a8 100644 --- a/.gitignore +++ b/.gitignore @@ -179,3 +179,4 @@ cython_debug/ # End of https://www.toptal.com/developers/gitignore/api/django *.bak +.vscode/ \ No newline at end of file diff --git a/api/customs/tasks/__init__.py b/api/customs/tasks/__init__.py index 6157c33..e63a0d3 100644 --- a/api/customs/tasks/__init__.py +++ b/api/customs/tasks/__init__.py @@ -1,2 +1,3 @@ from .microservice import * -from .internal_services import * \ No newline at end of file +from .internal_services import * +from .bulk_upload import * diff --git a/api/customs/tasks/bulk_upload.py b/api/customs/tasks/bulk_upload.py new file mode 100644 index 0000000..a2dd3b3 --- /dev/null +++ b/api/customs/tasks/bulk_upload.py @@ -0,0 +1,711 @@ +from celery import shared_task +from django.core.files.base import ContentFile +from django.utils import timezone +import os +import zipfile +import tempfile +import shutil +import logging +import re +import uuid +from datetime import datetime + +logger = logging.getLogger(__name__) + + +def normalize_filename(filename): + """ + Normaliza el nombre del archivo removiendo caracteres especiales, + espacios y asegurando consistencia. + """ + from unicodedata import normalize + filename = normalize('NFKD', filename).encode('ASCII', 'ignore').decode('ASCII') + filename = re.sub(r'[^\w\s.-]', '_', filename) + filename = re.sub(r'[\s()]+', '_', filename) + filename = re.sub(r'_+', '_', filename) + filename = filename.strip('_') + return filename + + +def get_clean_base_filename(filename): + """ + Obtiene el nombre base limpio sin el sufijo de Django. + """ + normalized = normalize_filename(filename) + name_without_ext, ext = os.path.splitext(normalized) + + django_suffix = extract_django_suffix(name_without_ext) + if django_suffix: + base_name = name_without_ext[:-8] + else: + base_name = name_without_ext + + base_name = re.sub(r'(_copy|_copia|_-_copia|_-_copy)(_\d+)?$', '', base_name) + + return base_name.lower().strip('_') + + +def extract_django_suffix(filename): + """ + Extrae el sufijo único que Django añade a los archivos. + """ + name_without_ext = os.path.splitext(filename)[0] + match = re.search(r'_([a-zA-Z0-9]{7})$', name_without_ext) + if match: + return match.group(1) + return None + + +def is_same_document(existing_doc, new_filename): + """ + Compara si un documento existente y un nuevo archivo son el mismo documento. + """ + existing_basename = os.path.basename(existing_doc.archivo.name) + existing_base = get_clean_base_filename(existing_basename) + + new_base = get_clean_base_filename(new_filename) + + existing_ext = existing_doc.extension.lower() + new_ext = os.path.splitext(new_filename)[1].lower().lstrip('.') + + return existing_base == new_base and existing_ext == new_ext + + +def procesar_archivo_m_con_nomenclatura(content, pedimento_instance): + """ + Procesa archivos con nomenclatura M8988852.300 (7 dígitos, punto, 3 dígitos) + y extrae información de registros específicos para actualizar el pedimento. + + Args: + content: bytes del contenido del archivo + pedimento_instance: instancia del modelo Pedimento + + Returns: + dict: Diccionario con información extraída + """ + try: + content_text = content.decode('utf-8', errors='ignore') + + registros = {} + + for line in content_text.splitlines(): + line = line.strip() + if not line: + continue + + parts = line.split('|') + if len(parts) < 2: + continue + + tipo_registro = parts[0] + + if tipo_registro not in registros: + registros[tipo_registro] = [] + registros[tipo_registro].append(parts) + + info_extraida = { + 'tiene_nomenclatura_especial': False, + 'registros_encontrados': list(registros.keys()), + 'detalles_registro_500': [], + 'detalles_registro_506': [], + 'detalles_registro_501': [], + 'detalles_registro_551': [], + 'detalles_registro_800': [], + 'detalles_registro_801': [], + 'actualizaciones_aplicadas': [] + } + + if '500' in registros: + info_extraida['tiene_nomenclatura_especial'] = True + + for reg_500 in registros['500']: + if len(reg_500) >= 1: + info_extraida['detalles_registro_500'].append({ + 'tipo_movimiento': reg_500[1] if len(reg_500) > 1 else None, + 'patente': reg_500[2] if len(reg_500) > 1 else None, + 'numero_pedimento': reg_500[3] if len(reg_500) > 1 else None, + 'aduana_seccion': reg_500[4] if len(reg_500) > 1 else None, + 'acuse_electronico': reg_500[5] if len(reg_500) > 1 else None, + }) + + for reg_506 in registros.get('506', []): + if len(reg_506) >= 1: + info_extraida['detalles_registro_506'].append({ + 'numero_pedimento': reg_506[1] if len(reg_506) > 1 else None, + 'tipo_fecha': reg_506[2] if len(reg_506) > 1 else None, + 'fecha': reg_506[3] if len(reg_506) > 1 else None + }) + + for reg_501 in registros.get('501', []): + if len(reg_501) >= 1: + info_extraida['detalles_registro_501'].append({ + 'patente': reg_501[1] if len(reg_501) > 1 else None, + 'numero_pedimento': reg_501[2] if len(reg_501) > 1 else None, + 'aduana_seccion': reg_501[3] if len(reg_501) > 1 else None, + 'rfc': reg_501[8] if len(reg_501) > 1 else None, + 'curp': reg_501[9] if len(reg_501) > 1 else None + }) + + for reg_551 in registros.get('551', []): + if len(reg_551) >= 1: + info_extraida['detalles_registro_551'].append({ + 'numero_pedimento': reg_501[1] if len(reg_501) > 1 else None, + 'fraccion_arancelaria': reg_551[2] if len(reg_551) > 1 else None, + 'partida': reg_551[3] if len(reg_551) > 1 else None, + 'subfraccion': reg_551[4] if len(reg_551) > 1 else None + }) + + for reg_801 in registros.get('800', []): + if len(reg_801) >= 1: + info_extraida['detalles_registro_800'].append({ + 'numero_pedimento': reg_801[1] if len(reg_801) > 1 else None + }) + + for reg_801 in registros.get('801', []): + if len(reg_801) >= 1: + info_extraida['detalles_registro_801'].append({ + 'total_partidas': reg_801[1] if len(reg_801) > 1 else None + }) + + actualizaciones = actualizar_pedimento_con_registros(pedimento_instance, registros) + info_extraida['actualizaciones_aplicadas'] = actualizaciones + + return info_extraida + + except Exception as e: + logger.error(f"Error al procesar archivo con nomenclatura especial: {str(e)}") + return { + 'tiene_nomenclatura_especial': False, + 'error': str(e), + 'registros_encontrados': [] + } + + +def actualizar_pedimento_con_registros(pedimento_instance, registros): + """ + Actualiza el pedimento con información extraída de los registros. + + Args: + pedimento_instance: Instancia del pedimento a actualizar + registros: Diccionario con registros parseados + + Returns: + list: Lista de actualizaciones aplicadas + """ + actualizaciones = [] + + try: + if '500' in registros and registros['500']: + for reg_500 in registros['500']: + if len(reg_500) >= 1: + if pedimento_instance.pedimento == reg_500[3]: + try: + pedimento_instance.aduana = reg_500[4] + actualizaciones.append(f"aduana actualizada a {reg_500[4]}") + except ValueError: + pass + + if '501' in registros and registros['501']: + for reg_501 in registros['501']: + if len(reg_501) >= 1: + rfc = reg_501[8] if len(reg_501) > 1 else None + + if rfc and not pedimento_instance.contribuyente and pedimento_instance.pedimento == reg_501[2]: + try: + from api.customs.models import Importador + importador, created = Importador.objects.get_or_create( + rfc=rfc, + defaults={ + 'nombre': f"Importador {rfc}", + 'organizacion': pedimento_instance.organizacion + } + ) + pedimento_instance.contribuyente = importador + if created: + actualizaciones.append(f"importador creado con RFC {rfc}") + else: + actualizaciones.append(f"importador asociado con RFC {rfc}") + except Exception as e: + logger.error(f"Error al crear/obtener importador: {str(e)}") + + if '501' in registros and registros['501']: + for reg_501 in registros['501']: + if len(reg_501) >= 1: + curp = reg_501[9] if len(reg_501) > 1 else None + if curp and not pedimento_instance.curp_apoderado and pedimento_instance.pedimento == reg_501[2]: + pedimento_instance.curp_apoderado = curp + actualizaciones.append(f"curp_apoderado actualizado a {curp}") + + if '501' in registros and registros['501']: + for reg_501 in registros['501']: + if len(reg_501) >= 1: + tipo_operacion = reg_501[4] if len(reg_501) > 1 else None + if tipo_operacion and pedimento_instance.pedimento == reg_501[2]: + + if tipo_operacion=='1': + nombre_tipo_op = "Importacion" + elif tipo_operacion=='2': + nombre_tipo_op = "Exportacion" + else: + nombre_tipo_op = f"Tipo {tipo_operacion}" + + try: + from api.customs.models import TipoOperacion + tipo_op_obj, created = TipoOperacion.objects.get_or_create( + id=tipo_operacion, + tipo=nombre_tipo_op, + defaults={'descripcion': f"Tipo de Operación {tipo_operacion}"} + ) + pedimento_instance.tipo_operacion = tipo_op_obj + if created: + actualizaciones.append(f"tipo_operacion creado con tipo {tipo_operacion}") + else: + actualizaciones.append(f"tipo_operacion asociado con tipo {tipo_operacion}") + except Exception as e: + logger.error(f"Error al crear/obtener tipo de operación: {str(e)}") + + if '501' in registros and registros['501']: + for reg_501 in registros['501']: + if len(reg_501) >= 1: + clave = reg_501[5] if len(reg_501) > 1 else None + if clave and pedimento_instance.pedimento == reg_501[2]: + pedimento_instance.clave_pedimento = clave + actualizaciones.append(f"clave pedimento actualizada a {clave}") + + if '506' in registros and registros['506']: + for reg_506 in registros['506']: + + if not pedimento_instance.pedimento == reg_506[1]: + continue + + if len(reg_506) >= 1: + tipo_fecha = reg_506[2] if len(reg_506) > 1 else None + fecha_str = reg_506[3] if len(reg_506) > 1 else None + + if not tipo_fecha == '2': + continue + + if fecha_str: + try: + if len(fecha_str) == 8: + fecha = datetime.strptime(fecha_str, '%d%m%Y').date() + elif len(fecha_str) == 6: + fecha = datetime.strptime(fecha_str, '%d%m%y').date() + else: + continue + + pedimento_instance.fecha_pago = fecha + actualizaciones.append(f"fecha_pago actualizada a {fecha}") + except (ValueError, TypeError): + pass + + num_partidas = 0 + if '551' in registros and registros['551']: + for reg_551 in registros['551']: + if not pedimento_instance.pedimento == reg_551[1]: + continue + + num_partidas += 1 + pedimento_instance.numero_partidas = num_partidas + actualizaciones.append(f"numero_partidas actualizado a {num_partidas}") + + + if actualizaciones: + pedimento_instance.save() + + except Exception as e: + logger.error(f"Error al actualizar pedimento con registros: {str(e)}") + actualizaciones.append(f"error: {str(e)}") + + return actualizaciones + + +@shared_task(bind=True, max_retries=3, time_limit=600) +def bulk_upload_record_task(self, organizacion_id, parametros, archivo_paths): + """ + Procesa archivos ZIP de pedimentos en segundo plano. + + Args: + organizacion_id: UUID de la organización + parametros: dict con keys: + - contribuyente + - fecha_pago_input + - clave_pedimento_input + - patente_input + - tipo_operacion_input + - aduana_input + - curp_apoderado_input + - partidas_input + archivo_paths: lista de rutas temporales de archivos ZIP + """ + from api.organization.models import Organizacion + from api.customs.models import Pedimento, Importador, TipoOperacion + from api.record.models import Document, DocumentType, Fuente + + created_pedimentos = [] + updated_pedimentos = [] + failed_records = [] + documents_created = 0 + temp_dir = None + + try: + organizacion = Organizacion.objects.get(id=organizacion_id) + + # Extraer parámetros + contribuyente = parametros.get('contribuyente', None) + fecha_pago_input = parametros.get('fecha_pago_input', None) + clave_pedimento_input = parametros.get('clave_pedimento_input', None) + patente_input = parametros.get('patente_input', None) + tipo_operacion_input = parametros.get('tipo_operacion_input', None) + aduana_input = parametros.get('aduana_input', None) + curp_apoderado_input = parametros.get('curp_apoderado_input', None) + partidas_input = parametros.get('partidas_input', None) + + # Regex patterns + 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})$') + + # Obtener DocumentType + try: + document_type = DocumentType.objects.get(nombre="Pedimento") + except DocumentType.DoesNotExist: + document_type = DocumentType.objects.create( + nombre="Pedimento", + descripcion="Documento de pedimento" + ) + + # Fuente + fuente, _ = Fuente.objects.get_or_create( + nombre="APP-EFC", + descripcion='Transmitido por la app de escritorio' + ) + + # Usar el directorio donde están los archivos (ya guardado en MEDIA_ROOT) + # El directorio base es el padre del primer archivo + if archivo_paths: + temp_dir = os.path.dirname(archivo_paths[0]) + else: + temp_dir = tempfile.mkdtemp() + + # Patrón para nomenclatura especial M8988852.300 + patron_nomenclatura = re.compile(r'^[m|M]\d{7}\.\d{3}$', re.IGNORECASE) + + existing_pedimento = None + + for archivo_path in archivo_paths: + archivo_name = os.path.basename(archivo_path).lower() + archivo_name_sin_extension = os.path.splitext(os.path.basename(archivo_path))[0] + + sub_dir = os.path.join(temp_dir, archivo_name_sin_extension) + os.makedirs(sub_dir, exist_ok=True) + + print(f"Procesando archivo: {archivo_name} en ruta temporal: {archivo_path}") + + if archivo_name.endswith('.zip'): + try: + with zipfile.ZipFile(archivo_path, 'r') as zip_ref: + zip_ref.extractall(sub_dir) + os.remove(archivo_path) # Eliminar el archivo ZIP después de extraerlo + except zipfile.BadZipFile as e: + failed_records.append({ + "file": archivo_path, + "archivo_original": archivo_name, + "error": f"Archivo ZIP corrupto o inválido: {str(e)}" + }) + continue + except Exception as e: + failed_records.append({ + "file": archivo_path, + "archivo_original": archivo_name, + "error": f"Error al extraer ZIP: {str(e)}" + }) + continue + else: + failed_records.append({ + "file": archivo_path, + "archivo_original": archivo_name, + "error": "Solo se admiten archivos ZIP" + }) + continue + + # Procesar archivos extraídos + for root, dirs, files in os.walk(temp_dir): + for file_name in files: + file_path = os.path.join(root, file_name) + relative_path = os.path.relpath(file_path, temp_dir) + + # Determinar folder_name + folder_name = None + if os.path.dirname(relative_path): + folder_parts = relative_path.split(os.sep) + folder_name = folder_parts[0] + else: + folder_name = os.path.splitext(file_name)[0] + + # 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: + archivo_original = folder_name + '.zip' + failed_records.append({ + "file": relative_path, + "archivo_original": archivo_original, + "error": f"Nomenclatura inválida: {folder_name}. Esperado: anio-aduana-patente-pedimento" + }) + continue + + if match: + anio, aduana, patente, pedimento_num = match.groups() + try: + anio_completo = 2000 + int(anio) if int(anio) < 50 else 1900 + int(anio) + fecha_pago = datetime(anio_completo, 1, 1).date() + except ValueError: + failed_records.append({ + "file": relative_path, + "archivo_original": folder_name + '.zip', + "error": f"Año inválido: {anio}" + }) + continue + + elif match_sin_anio: + aduana, patente, pedimento_num = match_sin_anio.groups() + + primer_digito_pedimento = int(pedimento_num[0]) if pedimento_num else 0 + año_actual = datetime.now().year + año_con_digito = int(str(año_actual)[:-1] + str(primer_digito_pedimento)) + + if año_con_digito <= año_actual: + año_final = año_con_digito + else: + año_final = año_con_digito - 10 + + anio = año_final % 100 + fecha_pago = datetime(año_final, 1, 1).date() + + # Generar pedimento_app + pedimento_app = f"{anio}-{aduana.zfill(2)}-{patente}-{pedimento_num}" + + # Verificar si el pedimento ya existe + existing_pedimento = Pedimento.objects.filter( + pedimento_app=pedimento_app, + organizacion=organizacion + ).first() + + if not existing_pedimento: + # Crear nuevo pedimento + try: + importador = None + if contribuyente: + importador, created = Importador.objects.get_or_create( + rfc=contribuyente, + defaults={ + 'nombre': f"Importador {contribuyente}", + 'organizacion': organizacion + } + ) + + tipo_op = None + if tipo_operacion_input: + tipo_op = TipoOperacion.objects.get(id=tipo_operacion_input) + + pedimento = Pedimento.objects.create( + organizacion=organizacion, + contribuyente=importador if importador else None, + pedimento=str(pedimento_num), + aduana=str(aduana), + patente=str(patente), + fecha_pago=fecha_pago_input if fecha_pago_input else fecha_pago, + curp_apoderado=curp_apoderado_input if curp_apoderado_input else "", + numero_partidas=partidas_input if partidas_input else 0, + tipo_operacion=tipo_op if tipo_op else None, + pedimento_app=pedimento_app, + agente_aduanal=f"Agente {patente}", + clave_pedimento=clave_pedimento_input if clave_pedimento_input else "A1" + ) + + existing_pedimento = pedimento + + created_pedimentos.append({ + "id": str(pedimento.id), + "pedimento_app": pedimento_app, + "contribuyente": getattr(importador, 'rfc', None), + "contribuyente_nombre": getattr(importador, 'nombre', None) + }) + + except Exception as e: + failed_records.append({ + "file": relative_path, + "archivo_original": folder_name + '.zip', + "error": f"Error al crear pedimento: {str(e)}" + }) + continue + else: + # Actualizar pedimento existente + if contribuyente: + importador, created = Importador.objects.get_or_create( + rfc=contribuyente, + defaults={ + 'nombre': f"Importador {contribuyente}", + 'organizacion': organizacion + } + ) + + importador_db = existing_pedimento.contribuyente + if importador_db: + if importador_db != importador: + existing_pedimento.contribuyente = importador + else: + existing_pedimento.contribuyente = importador + + existing_pedimento.save() + + # Actualizar Tipo Operacion + if tipo_operacion_input: + tipo_op = TipoOperacion.objects.get(id=tipo_operacion_input) + if tipo_op and not existing_pedimento.tipo_operacion: + existing_pedimento.tipo_operacion = tipo_op + existing_pedimento.save() + + # Actualizar fecha de pago + if fecha_pago_input: + fecha_db = existing_pedimento.fecha_pago + if fecha_db: + if isinstance(fecha_db, datetime): + fecha_db = fecha_db.date() + if fecha_db.month == 1 and fecha_db.day == 1: + existing_pedimento.fecha_pago = fecha_pago_input + existing_pedimento.save() + else: + existing_pedimento.fecha_pago = fecha_pago_input + existing_pedimento.save() + + # Actualizar clave_pedimento + if clave_pedimento_input: + clave_pedimento = existing_pedimento.clave_pedimento + if not clave_pedimento or clave_pedimento.strip() != clave_pedimento_input.strip(): + existing_pedimento.clave_pedimento = clave_pedimento_input + existing_pedimento.save() + + # Actualizar curp_apoderado + if curp_apoderado_input: + if not existing_pedimento.curp_apoderado: + existing_pedimento.curp_apoderado = curp_apoderado_input + existing_pedimento.save() + + # Actualizar partidas + if partidas_input: + num_partidas = existing_pedimento.numero_partidas + if not num_partidas or num_partidas <= 0: + existing_pedimento.numero_partidas = partidas_input + existing_pedimento.save() + + # Crear documento asociado al pedimento + try: + with open(file_path, 'rb') as f: + file_content = f.read() + + file_name_lower = file_name.lower() + tiene_nomenclatura_especial = False + info_extraida = {} + + nombre_base, extension = os.path.splitext(file_name) + + if patron_nomenclatura.match(file_name_lower): + 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", + "documento": file_name + }) + + documents_created += 1 + else: + # Crear nuevo documento + 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 + }) + + documents_created += 1 + + except Exception as e: + failed_records.append({ + "file": relative_path, + "archivo_original": folder_name + '.zip', + "error": f"Error al crear documento: {str(e)}" + }) + continue + + # Actualizar estado de expediente + if documents_created > 0 and existing_pedimento: + existing_pedimento.existe_expediente = True + existing_pedimento.save() + + return { + 'status': 'completed', + 'created_pedimentos': created_pedimentos, + 'updated_pedimentos': updated_pedimentos, + 'failed_records': failed_records, + 'documents_created': documents_created, + 'tieneError': len(failed_records) > 0 + } + + except Exception as e: + logger.error(f"Error en bulk_upload_record_task: {str(e)}") + raise self.retry(exc=e, countdown=60) + + finally: + # Limpiar directorio temporal + if temp_dir and os.path.exists(temp_dir): + try: + shutil.rmtree(temp_dir) + except Exception as e: + logger.warning(f"Error al limpiar directorio temporal: {e}") diff --git a/api/customs/tasks/microservice_v2.py b/api/customs/tasks/microservice_v2.py index 7d68986..c98bf98 100644 --- a/api/customs/tasks/microservice_v2.py +++ b/api/customs/tasks/microservice_v2.py @@ -217,10 +217,24 @@ def procesar_pedimentos_completos(organizacion_id): pedimentos = Pedimento.objects.filter(organizacion_id=organizacion_id) respuestas = [] for pedimento in pedimentos: + + if not pedimento.contribuyente: + print(f"Pedimento {pedimento.pedimento} no tiene contribuyente") + continue + + credencial_importador = CredencialesImportador.objects.filter( + rfc=pedimento.contribuyente + ).first() + + if not credencial_importador: + print(f"No credencial para RFC {pedimento.contribuyente.rfc}") + continue + if not pedimento.documents.filter(document_type=2).exists(): # Tipo 2: Pedimento Completo # Convertir el pedimento a JSON usando el serializer pedimento_dict = pedimento_to_dict(pedimento) - credenciales = Vucem.objects.filter(id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id).first() + # credenciales = Vucem.objects.filter(id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id).first() + credenciales = Vucem.objects.filter(id=credencial_importador.vucem.id).first() if not credenciales: print(f"No se encontraron credenciales para el pedimento {pedimento.pedimento_app}") @@ -231,9 +245,13 @@ def procesar_pedimentos_completos(organizacion_id): "pedimento": pedimento_dict, "credencial": credenciales_dict } + + url = f"{SERVICE_API_URL_V2}/services/pedimento_completo" + dataJson = json.dumps(payload) + response = requests.post( - f"{SERVICE_API_URL_V2}/services/pedimento_completo", - data=json.dumps(payload), + url, + data=dataJson, headers={"Content-Type": "application/json"} ) # Aquí puedes continuar con el resto de tu lógica diff --git a/api/customs/views.py b/api/customs/views.py index f810390..18a9aee 100644 --- a/api/customs/views.py +++ b/api/customs/views.py @@ -1465,6 +1465,600 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada return Response(response_data, status=response_status) + @action(detail=False, methods=['post'], url_path='bulk-upload-record-zip', parser_classes=[MultiPartParser, FormParser]) + def bulk_upload_record(self, request): + """ + Endpoint para subir un archivo zip con documentos de pedimentos. + Se espera un archivo zip con nomenclatura esperada de archivos: anio-aduana-patente-pedimento o aduana-patente-pedimento: ejemplo: 24-07-3420-1234567.zip o 07-3420-1234567.zip + - 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) + + El endpoint procesará cada registro, verificando si el pedimento existe y actualizando su estado o creando un nuevo registro según sea necesario. + + Respuesta: + { + "message": "Archivo procesado", + "processed_records": 100, + "created_pedimentos": [...], + "updated_pedimentos": [...], + "failed_records": [...] + } + """ + created_pedimentos = [] + updated_pedimentos = [] + failed_records = [] + errors = [] + documents_created = 0 + temp_dir = None + + # Implementación del procesamiento del archivo CSV y actualización/creación de pedimentos + # Este es un ejemplo básico y se puede expandir según los requisitos específicos + archivos = request.FILES.get('archivos') + if archivos and hasattr(archivos, 'name'): # Es un archivo individual + archivos = [archivos] # Convertir a lista para procesar de manera uniforme + else: + archivos = request.FILES.getlist('archivos') + + # Validar datos requeridos + contribuyente = request.data.get('contribuyente') + fecha_pago_input = request.data.get('fecha_pago') + clave_pedimento_input = request.data.get('clave_pedimento') + patente_input = request.data.get('patente') + tipo_operacion_input = request.data.get('tipo_operacion') + aduana_input = request.data.get('aduana') + contribuyente_input = request.data.get('contribuyente') + curp_apoderado_input = request.data.get('curp_apoderado') + partidas_input = request.data.get('partidas') + fuente_archivos = request.data.get('partidas') + + # Validar organización del usuario + if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'): + return Response( + { + "tieneError": True, + "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})$') + + if not archivos: + return Response( + { "tieneError": True, + "error": "Se requiere un archivo para procesar" + }, + status=status.HTTP_400_BAD_REQUEST + ) + + # Obtener DocumentType ANTES de la transacción atómica + try: + # Primero intentar obtener si ya existe + try: + document_type = DocumentType.objects.get(nombre="Pedimento") + except DocumentType.DoesNotExist: + # Si no existe, crear uno nuevo + document_type = DocumentType.objects.create( + nombre="Pedimento", + descripcion="Documento de pedimento" + ) + except Exception as e: + # Como fallback, intentar obtener cualquier DocumentType existente + try: + document_type = DocumentType.objects.first() + if not document_type: + 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: + return Response( + { + "tieneError": True, + "error": f"Error crítico al configurar tipo de documento: {str(e)}" + }, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + + try: + with transaction.atomic(): + # Crear directorio temporal + temp_dir = tempfile.mkdtemp() + + # Procesar cada archivo enviado + for idx, archivo in enumerate(archivos): + archivo_name = archivo.name.lower() + + # 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) + + if archivo_name.endswith('.zip'): + # Manejar 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( + { + "tieneError": True, + "error": f"Archivo ZIP corrupto o inválido: {archivo.name} - {str(e)}" + }, + status=status.HTTP_400_BAD_REQUEST + ) + except Exception as e: + return Response( + { + "tieneError": True, + "error": f"Error al extraer ZIP {archivo.name}: {str(e)}" + }, + status=status.HTTP_400_BAD_REQUEST + ) + else: + return Response( + { "tieneError": True, + "error": "Solo se admiten archivos ZIP" + },status=status.HTTP_400_BAD_REQUEST + ) + + # Recorrer todos los archivos extraídos o el directorio + for root, dirs, files in os.walk(temp_dir): + for file_name in files: + 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) + + # 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] + + # 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: + # 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_records.append({ + "file": relative_path, + "archivo_original": archivo_original, + "error": f"Nomenclatura inválida: {folder_name}. Esperado: anio-aduana-patente-pedimento" + }) + continue + + if match: + anio, aduana, patente, pedimento_num = match.groups() + # 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() + except ValueError: + archivo_original = folder_name + ('.zip' if any(f.endswith('.zip') for f in [a.name for a in archivos]) else '.rar') + failed_records.append({ + "file": relative_path, + "archivo_original": archivo_original, + "error": f"Año inválido: {anio}" + }) + continue + + elif match_sin_anio: + # Formato sin año: aduana-patente-pedimento + aduana, patente, pedimento_num = match_sin_anio.groups() + + # 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() + + # Generar pedimento_app + pedimento_app = f"{anio}-{aduana.zfill(2)}-{patente}-{pedimento_num}" + + # Verificar si el pedimento ya existe + existing_pedimento = Pedimento.objects.filter( + pedimento_app=pedimento_app, + organizacion=organizacion + ).first() + + if not existing_pedimento: + # Crear nuevo pedimento + try: + importador = None + if contribuyente: + # Obtener o crear el importador + importador, created = Importador.objects.get_or_create( + rfc=contribuyente, + defaults={ + 'nombre': f"Importador {contribuyente}", + 'organizacion': organizacion + } + ) + if tipo_operacion_input: + tipo_operacion_input = TipoOperacion.objects.get(id=tipo_operacion_input) + + pedimento = Pedimento.objects.create( + organizacion=organizacion, + contribuyente=importador if importador else None, + pedimento=str(pedimento_num), + aduana=str(aduana), + patente=str(patente), + fecha_pago=fecha_pago_input if fecha_pago_input else fecha_pago, + curp_apoderado=curp_apoderado_input if curp_apoderado_input else "", + numero_partidas=partidas_input if partidas_input else 0, + tipo_operacion=tipo_operacion_input if tipo_operacion_input else None, + pedimento_app=pedimento_app, + agente_aduanal=f"Agente {patente}", # Valor por defecto + clave_pedimento=clave_pedimento_input if clave_pedimento_input else "A1" # Valor por defecto + ) + + created_pedimentos.append({ + "id": str(pedimento.id), + "pedimento_app": pedimento_app, + "contribuyente": getattr(importador, 'rfc', None), + "contribuyente_nombre": getattr(importador, 'nombre', None) + }) + + except Exception as e: + archivo_original = folder_name + ('.zip' if any(f.endswith('.zip') for f in [a.name for a in archivos]) else '.rar') + + failed_records.append({ + "file": relative_path, + "archivo_original": archivo_original, + "error": f"Error al crear pedimento: {str(e)}" + }) + continue + else: # Usar pedimento existente + + # Actualizar Importador + if contribuyente: + importador, created = Importador.objects.get_or_create( + rfc=contribuyente, + defaults={ + 'nombre': f"Importador {contribuyente}", + 'organizacion': organizacion + } + ) + + importador_db = existing_pedimento.contribuyente + if importador_db: + if importador_db != importador: + existing_pedimento.contribuyente = importador + else: + existing_pedimento.contribuyente = importador + + existing_pedimento.save() + + # Actualizar Tipo Operacion + if tipo_operacion_input: + tipo_operacion_input = TipoOperacion.objects.get(id=tipo_operacion_input) + if tipo_operacion_input: + tipo_operacion_db = existing_pedimento.tipo_operacion + if not tipo_operacion_db: + existing_pedimento.tipo_operacion = tipo_operacion_input + existing_pedimento.save() + + # Actualizar fecha de pago solo cuando aun no esta actualizado + if fecha_pago_input: + fecha_db = existing_pedimento.fecha_pago + + # Verificar si hay fecha en BD + if fecha_db: + # Asegurar que trabajamos con date + if isinstance(fecha_db, datetime): + fecha_db = fecha_db.date() + + # Si la fecha existe y es 1 de enero, actualizar + if fecha_db.month == 1 and fecha_db.day == 1: + # Actualizar Fecha + existing_pedimento.fecha_pago = fecha_pago_input + existing_pedimento.save() + else: + existing_pedimento.fecha_pago = fecha_pago_input + existing_pedimento.save() + + if clave_pedimento_input: + clavePedimento = existing_pedimento.clave_pedimento + + if not clavePedimento or clavePedimento.strip() != clave_pedimento_input.strip(): + existing_pedimento.clave_pedimento = clave_pedimento_input + existing_pedimento.save() + + if curp_apoderado_input: + curp = existing_pedimento.curp_apoderado + if not curp: + existing_pedimento.curp_apoderado = curp_apoderado_input + existing_pedimento.save() + + if partidas_input: + numPartidas = existing_pedimento.numero_partidas + if not numPartidas: + existing_pedimento.numero_partidas = partidas_input + existing_pedimento.save() + elif numPartidas <= 0: + existing_pedimento.numero_partidas = partidas_input + existing_pedimento.save() + + pedimento = existing_pedimento + + # Crear documento asociado al pedimento + try: + # Leer el archivo desde el directorio temporal + with open(file_path, 'rb') as f: + file_content = f.read() + + # Verificar si el archivo tiene la nomenclatura especial M8988852.300 + file_name_lower = file_name.lower() + tiene_nomenclatura_especial = False + info_extraida = {} + + # Patrón: 7 dígitos, punto, 3 dígitos (ej: M8988852.300) + patron_nomenclatura = re.compile(r'^[m|M]\d{7}\.\d{3}$', re.IGNORECASE) + + # Separar nombre base y extensión + nombre_base, extension = os.path.splitext(file_name) + + if patron_nomenclatura.match(file_name_lower): + tiene_nomenclatura_especial = True + + # Procesar el archivo con el método auxiliar + info_extraida = procesar_archivo_m_con_nomenclatura(file_content, pedimento) + + if info_extraida.get('tiene_nomenclatura_especial', False): + # Agregar información de procesamiento a los datos de respuesta + if 'procesamiento_archivos' not in locals(): + procesamiento_archivos = [] + + procesamiento_archivos.append({ + 'archivo': file_name, + 'nomenclatura_especial': True, + 'registros_encontrados': info_extraida.get('registros_encontrados', []), + 'actualizaciones': info_extraida.get('actualizaciones_aplicadas', []) + }) + + # Crear ContentFile que Django puede manejar correctamente + django_file = ContentFile(file_content, name=file_name) + + fuente, created = Fuente.objects.get_or_create( + nombre="APP-EFC", + descripcion='Transmitido por la app de escritorio' + ) + + # Buscar si ya existe un documento con el mismo nombre para este pedimento + existing_documents = Document.objects.filter( + pedimento_id=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: + # Opcional: Eliminar el archivo físico anterior + try: + if existing_document.archivo and os.path.exists(existing_document.archivo.path): + os.remove(existing_document.archivo.path) + except (ValueError, OSError) as e: + pass + + # Actualizar el documento existente con el nuevo archivo y datos + existing_document.archivo = django_file + existing_document.size = len(file_content) + existing_document.extension = extension + existing_document.updated_at = timezone.now() # Si tienes este campo + existing_document.save() + + documents_created += 1 + + else: + # 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=fuente.id, + archivo=django_file, + size=len(file_content), + extension=os.path.splitext(file_name)[1].lower().lstrip('.') + ) + documents_created += 1 + + except Exception as e: + archivo_original = folder_name + ('.zip' if any(f.endswith('.zip') for f in [a.name for a in archivos]) else '.rar') + failed_records.append({ + "file": relative_path, + "archivo_original": archivo_original, + "error": f"Error al crear documento: {str(e)}" + }) + continue + + if documents_created > 0 and existing_pedimento: + existing_pedimento.existe_expediente = True + existing_pedimento.save() + + except Exception as e: + return Response( + { "tieneError": True, + "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 = { + "tieneError": False, + "failed_files": failed_records, + "processed_files": len(archivos), + } + + if failed_records: + response_data["tieneError"] = True + response_data.update({ + "message": "Procesamiento completado con algunos errores", + "errors": [item["error"] for item in failed_records] + }) + 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) + + @action(detail=False, methods=['post'], url_path='bulk-upload-record-zip-async', parser_classes=[MultiPartParser, FormParser]) + def bulk_upload_record_async(self, request): + """ + Endpoint asíncrono para subir archivos ZIP de pedimentos en segundo plano. + Retorna task_id para polling de estado. + + Respuesta: + { + "task_id": "uuid-de-la-tarea", + "status": "PENDING", + "message": "Procesamiento iniciado en segundo plano" + } + """ + import uuid + import os + from django.conf import settings + from api.customs.tasks.bulk_upload import bulk_upload_record_task + + # Obtener archivos + archivos = request.FILES.get('archivos') + if archivos and hasattr(archivos, 'name'): + archivos = [archivos] + else: + archivos = request.FILES.getlist('archivos') + + # Validar datos requeridos + # contribuyente = request.data.get('contribuyente') + + # if not contribuyente: + # return Response( + # {"error": "Se requiere el campo 'contribuyente'"}, + # status=status.HTTP_400_BAD_REQUEST + # ) + + if not archivos: + return Response( + {'tieneError': True, + "mensaje": "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( + {'tieneError': True, + "mensaje": "Usuario no autenticado o sin organización"}, + status=status.HTTP_400_BAD_REQUEST + ) + + organizacion = request.user.organizacion + + # Preparar parámetros + parametros = { + 'contribuyente': request.data.get('contribuyente'), + 'fecha_pago_input': request.data.get('fecha_pago'), + 'clave_pedimento_input': request.data.get('clave_pedimento'), + 'patente_input': request.data.get('patente'), + 'tipo_operacion_input': request.data.get('tipo_operacion'), + 'aduana_input': request.data.get('aduana'), + 'curp_apoderado_input': request.data.get('curp_apoderado'), + 'partidas_input': request.data.get('partidas') + } + + # Guardar archivos temporalmente en MEDIA_ROOT (compartido entre contenedores) + temp_dir = os.path.join(settings.MEDIA_ROOT, 'temp_bulk_upload', str(uuid.uuid4())) + os.makedirs(temp_dir, exist_ok=True) + archivo_paths = [] + + try: + for archivo in archivos: + if not archivo.name.lower().endswith('.zip'): + return Response( + {'tieneError': True, + "mensaje": f"Solo se admiten archivos ZIP: {archivo.name}"}, + status=status.HTTP_400_BAD_REQUEST + ) + + file_path = os.path.join(temp_dir, archivo.name) + with open(file_path, 'wb') as f: + for chunk in archivo.chunks(): + f.write(chunk) + archivo_paths.append(file_path) + + # Llamar tarea Celery + task = bulk_upload_record_task.apply_async( + args=[str(organizacion.id), parametros, archivo_paths] + ) + + return Response({ + 'tieneError': False, + 'task_id': task.id, + 'status': 'PENDING', + 'mensaje': 'Procesamiento iniciado en segundo plano' + }, status=status.HTTP_202_ACCEPTED) + + # bulk_upload_record_task_1(str(organizacion.id), parametros, archivo_paths) + # return Response({ + # 'task_id': str(uuid.uuid4()), # Generar un UUID ficticio para la respuesta, ya que no estamos usando Celery en este ejemplo + # 'status': 'PENDING', + # 'message': 'Procesamiento iniciado en segundo plano' + # }, status=status.HTTP_202_ACCEPTED) + + except Exception as e: + # Limpiar archivos temporales SOLO si hay error antes de lanzar la tarea + if temp_dir and os.path.exists(temp_dir): + import shutil + shutil.rmtree(temp_dir) + return Response( + {'tieneError': True, + "mensaje": f"Error al iniciar procesamiento: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + my_tags = ['Pedimentos'] @@ -1843,6 +2437,394 @@ class EjecutarComandoView(APIView): my_tags = ['Procesamientos_Pedimentos'] +def bulk_upload_record_task_1(organizacion_id, parametros, archivo_paths): + """ + Procesa archivos ZIP de pedimentos en segundo plano. + + Args: + organizacion_id: UUID de la organización + parametros: dict con keys: + - contribuyente + - fecha_pago_input + - clave_pedimento_input + - patente_input + - tipo_operacion_input + - aduana_input + - curp_apoderado_input + - partidas_input + archivo_paths: lista de rutas temporales de archivos ZIP + """ + from api.organization.models import Organizacion + from api.customs.models import Pedimento, Importador, TipoOperacion + from api.record.models import Document, DocumentType, Fuente + + created_pedimentos = [] + updated_pedimentos = [] + failed_records = [] + documents_created = 0 + temp_dir = None + + try: + organizacion = Organizacion.objects.get(id=organizacion_id) + + # Extraer parámetros + contribuyente = parametros.get('contribuyente', None) + fecha_pago_input = parametros.get('fecha_pago_input', None) + clave_pedimento_input = parametros.get('clave_pedimento_input', None) + patente_input = parametros.get('patente_input', None) + tipo_operacion_input = parametros.get('tipo_operacion_input', None) + aduana_input = parametros.get('aduana_input', None) + curp_apoderado_input = parametros.get('curp_apoderado_input', None) + partidas_input = parametros.get('partidas_input', None) + + # Regex patterns + 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})$') + + # Obtener DocumentType + try: + document_type = DocumentType.objects.get(nombre="Pedimento") + except DocumentType.DoesNotExist: + document_type = DocumentType.objects.create( + nombre="Pedimento", + descripcion="Documento de pedimento" + ) + + # Fuente + fuente, _ = Fuente.objects.get_or_create( + nombre="APP-EFC", + descripcion='Transmitido por la app de escritorio' + ) + + # Usar el directorio donde están los archivos (ya guardado en MEDIA_ROOT) + # El directorio base es el padre del primer archivo + if archivo_paths: + temp_dir = os.path.dirname(archivo_paths[0]) + else: + temp_dir = tempfile.mkdtemp() + + # Patrón para nomenclatura especial M8988852.300 + patron_nomenclatura = re.compile(r'^[m|M]\d{7}\.\d{3}$', re.IGNORECASE) + + existing_pedimento = None + + for archivo_path in archivo_paths: + archivo_name = os.path.basename(archivo_path).lower() + archivo_name_sin_extension = os.path.splitext(os.path.basename(archivo_path))[0] + + sub_dir = os.path.join(temp_dir, archivo_name_sin_extension) + os.makedirs(sub_dir, exist_ok=True) + + print(f"Procesando archivo: {archivo_name} en ruta temporal: {archivo_path}") + + if archivo_name.endswith('.zip'): + try: + with zipfile.ZipFile(archivo_path, 'r') as zip_ref: + zip_ref.extractall(sub_dir) + os.remove(archivo_path) # Eliminar el archivo ZIP después de extraerlo + except zipfile.BadZipFile as e: + failed_records.append({ + "file": archivo_path, + "archivo_original": archivo_name, + "error": f"Archivo ZIP corrupto o inválido: {str(e)}" + }) + continue + except Exception as e: + failed_records.append({ + "file": archivo_path, + "archivo_original": archivo_name, + "error": f"Error al extraer ZIP: {str(e)}" + }) + continue + else: + failed_records.append({ + "file": archivo_path, + "archivo_original": archivo_name, + "error": "Solo se admiten archivos ZIP" + }) + continue + + # Procesar archivos extraídos + for root, dirs, files in os.walk(temp_dir): + for file_name in files: + file_path = os.path.join(root, file_name) + relative_path = os.path.relpath(file_path, temp_dir) + + # Determinar folder_name + folder_name = None + if os.path.dirname(relative_path): + folder_parts = relative_path.split(os.sep) + folder_name = folder_parts[0] + else: + folder_name = os.path.splitext(file_name)[0] + + # 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: + archivo_original = folder_name + '.zip' + failed_records.append({ + "file": relative_path, + "archivo_original": archivo_original, + "error": f"Nomenclatura inválida: {folder_name}. Esperado: anio-aduana-patente-pedimento" + }) + continue + + if match: + anio, aduana, patente, pedimento_num = match.groups() + try: + anio_completo = 2000 + int(anio) if int(anio) < 50 else 1900 + int(anio) + fecha_pago = datetime(anio_completo, 1, 1).date() + except ValueError: + failed_records.append({ + "file": relative_path, + "archivo_original": folder_name + '.zip', + "error": f"Año inválido: {anio}" + }) + continue + + elif match_sin_anio: + aduana, patente, pedimento_num = match_sin_anio.groups() + + primer_digito_pedimento = int(pedimento_num[0]) if pedimento_num else 0 + año_actual = datetime.now().year + año_con_digito = int(str(año_actual)[:-1] + str(primer_digito_pedimento)) + + if año_con_digito <= año_actual: + año_final = año_con_digito + else: + año_final = año_con_digito - 10 + + anio = año_final % 100 + fecha_pago = datetime(año_final, 1, 1).date() + + # Generar pedimento_app + pedimento_app = f"{anio}-{aduana.zfill(2)}-{patente}-{pedimento_num}" + + # Verificar si el pedimento ya existe + existing_pedimento = Pedimento.objects.filter( + pedimento_app=pedimento_app, + organizacion=organizacion + ).first() + + if not existing_pedimento: + # Crear nuevo pedimento + try: + importador = None + if contribuyente: + importador, created = Importador.objects.get_or_create( + rfc=contribuyente, + defaults={ + 'nombre': f"Importador {contribuyente}", + 'organizacion': organizacion + } + ) + + tipo_op = None + if tipo_operacion_input: + tipo_op = TipoOperacion.objects.get(id=tipo_operacion_input) + + pedimento = Pedimento.objects.create( + organizacion=organizacion, + contribuyente=importador if importador else None, + pedimento=str(pedimento_num), + aduana=str(aduana), + patente=str(patente), + fecha_pago=fecha_pago_input if fecha_pago_input else fecha_pago, + curp_apoderado=curp_apoderado_input if curp_apoderado_input else "", + numero_partidas=partidas_input if partidas_input else 0, + tipo_operacion=tipo_op if tipo_op else None, + pedimento_app=pedimento_app, + agente_aduanal=f"Agente {patente}", + clave_pedimento=clave_pedimento_input if clave_pedimento_input else "A1" + ) + + existing_pedimento = pedimento + + created_pedimentos.append({ + "id": str(pedimento.id), + "pedimento_app": pedimento_app, + "contribuyente": getattr(importador, 'rfc', None), + "contribuyente_nombre": getattr(importador, 'nombre', None) + }) + + except Exception as e: + failed_records.append({ + "file": relative_path, + "archivo_original": folder_name + '.zip', + "error": f"Error al crear pedimento: {str(e)}" + }) + continue + else: + # Actualizar pedimento existente + if contribuyente: + importador, created = Importador.objects.get_or_create( + rfc=contribuyente, + defaults={ + 'nombre': f"Importador {contribuyente}", + 'organizacion': organizacion + } + ) + + importador_db = existing_pedimento.contribuyente + if importador_db: + if importador_db != importador: + existing_pedimento.contribuyente = importador + else: + existing_pedimento.contribuyente = importador + + existing_pedimento.save() + + # Actualizar Tipo Operacion + if tipo_operacion_input: + tipo_op = TipoOperacion.objects.get(id=tipo_operacion_input) + if tipo_op and not existing_pedimento.tipo_operacion: + existing_pedimento.tipo_operacion = tipo_op + existing_pedimento.save() + + # Actualizar fecha de pago + if fecha_pago_input: + fecha_db = existing_pedimento.fecha_pago + if fecha_db: + if isinstance(fecha_db, datetime): + fecha_db = fecha_db.date() + if fecha_db.month == 1 and fecha_db.day == 1: + existing_pedimento.fecha_pago = fecha_pago_input + existing_pedimento.save() + else: + existing_pedimento.fecha_pago = fecha_pago_input + existing_pedimento.save() + + # Actualizar clave_pedimento + if clave_pedimento_input: + clave_pedimento = existing_pedimento.clave_pedimento + if not clave_pedimento or clave_pedimento.strip() != clave_pedimento_input.strip(): + existing_pedimento.clave_pedimento = clave_pedimento_input + existing_pedimento.save() + + # Actualizar curp_apoderado + if curp_apoderado_input: + if not existing_pedimento.curp_apoderado: + existing_pedimento.curp_apoderado = curp_apoderado_input + existing_pedimento.save() + + # Actualizar partidas + if partidas_input: + num_partidas = existing_pedimento.numero_partidas + if not num_partidas or num_partidas <= 0: + existing_pedimento.numero_partidas = partidas_input + existing_pedimento.save() + + # Crear documento asociado al pedimento + try: + with open(file_path, 'rb') as f: + file_content = f.read() + + file_name_lower = file_name.lower() + tiene_nomenclatura_especial = False + info_extraida = {} + + nombre_base, extension = os.path.splitext(file_name) + + if patron_nomenclatura.match(file_name_lower): + 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", + "documento": file_name + }) + + documents_created += 1 + else: + # Crear nuevo documento + 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 + }) + + documents_created += 1 + + except Exception as e: + failed_records.append({ + "file": relative_path, + "archivo_original": folder_name + '.zip', + "error": f"Error al crear documento: {str(e)}" + }) + continue + + # Actualizar estado de expediente + if documents_created > 0 and existing_pedimento: + existing_pedimento.existe_expediente = True + existing_pedimento.save() + + return { + 'status': 'completed', + 'created_pedimentos': created_pedimentos, + 'updated_pedimentos': updated_pedimentos, + 'failed_records': failed_records, + 'documents_created': documents_created, + 'tieneError': len(failed_records) > 0 + } + + except Exception as e: + pass + + finally: + # Limpiar directorio temporal + if temp_dir and os.path.exists(temp_dir): + try: + shutil.rmtree(temp_dir) + except Exception as e: + pass + # helper | reglas para formato de docuemnto antes de cargarlo def normalize_filename(filename): """ diff --git a/api/tasks/urls.py b/api/tasks/urls.py index 7040e1a..9c667dc 100644 --- a/api/tasks/urls.py +++ b/api/tasks/urls.py @@ -1,10 +1,12 @@ from rest_framework.routers import DefaultRouter from .views import TaskViewSet from django.urls import path, include +from .views import TaskStatusView router = DefaultRouter() router.register(r'tasks', TaskViewSet) urlpatterns = [ path('', include(router.urls)), + path('status//', TaskStatusView.as_view(), name='task-status'), ] diff --git a/api/tasks/views.py b/api/tasks/views.py index 917205b..2a68fa7 100644 --- a/api/tasks/views.py +++ b/api/tasks/views.py @@ -48,4 +48,58 @@ class TaskViewSet(LoggingMixin,viewsets.ModelViewSet,OrganizacionFiltradaMixin): # else: # return self.queryset.filter(organizacion_id=user.organizacion.id) return queryset + + +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status +from celery.result import AsyncResult + + +class TaskStatusView(APIView): + """ + Vista para consultar el estado de tareas de Celery. + """ + permission_classes = [IsAuthenticated] + + def get(self, request, task_id): + """ + Consulta el estado de una tarea de Celery. + + Returns: + - PENDING: La tarea está esperando ser procesada + - STARTED: La tarea ha sido iniciada + - SUCCESS: La tarea se completó exitosamente + - FAILURE: La tarea falló + - RETRY: La tarea está reintentando + """ + try: + task_result = AsyncResult(task_id) + + response_data = { + 'task_id': task_id, + 'status': task_result.state, + 'ready': task_result.ready(), + 'successful': task_result.successful() if task_result.ready() else None, + } + + if task_result.ready() and task_result.successful(): + try: + response_data['result'] = task_result.result + except Exception: + pass + + if task_result.state == 'FAILURE': + response_data['error'] = str(task_result.info) + + if task_result.state == 'STARTED': + response_data['info'] = str(task_result.info) if task_result.info else None + + return Response(response_data, status=status.HTTP_200_OK) + + except Exception as e: + return Response( + {'error': f'Error al consultar tarea: {str(e)}'}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) \ No newline at end of file