Compare commits

..

31 Commits

Author SHA1 Message Date
9bbed42cf3 Merge pull request 'feature/agregar eventos en las tareas de fondo, se modificaron modelos para capturar cuales si deben accionar tareas de fondo y cuales no necesariamente tienen que accionar tareas de fondo' (#29) from feature/background-tasks into main
Reviewed-on: #29
2026-05-19 15:02:24 +00:00
1966218081 feature/agregar eventos en las tareas de fondo, se modificaron modelos para capturar cuales si deben accionar tareas de fondo y cuales no necesariamente tienen que accionar tareas de fondo 2026-05-19 08:59:56 -06:00
b57ce83dc5 Merge pull request 'feature/T2026-05-016-y-T2026-05-031' (#28) from feature/T2026-05-016-y-T2026-05-031 into main
Reviewed-on: #28
2026-05-18 18:05:26 +00:00
Dulce
c2ae752932 fix/T2025-09-007 corregir documentos duplicados 2026-05-18 11:55:46 -06:00
Dulce
8cc0b9f573 feature/T2026-05-016 implementar cargas de tareas en background e implementar y corregir auditoria para datastages 2026-05-18 11:54:46 -06:00
Dulce
3a636c14ae T2026-05-030 2026-05-18 11:51:30 -06:00
Dulce
63f051c566 feature/T2026-05-031 agregar multiples rfc's a un usuario 2026-05-18 11:47:41 -06:00
c890e79394 Merge pull request 'feature/implementacion de gestor de informacion y archivos minIO' (#27) from feature/minio-implementation into main
Reviewed-on: #27
2026-04-22 18:02:58 +00:00
Dulce
39504e196c feature/implementacion de gestor de informacion y archivos minIO 2026-04-22 11:10:05 -06:00
69d07f2713 Merge pull request 'filtros de pedimento completo' (#26) from filtros-doctype into main
Reviewed-on: #26
2026-03-27 14:30:11 +00:00
Dulce
27c8d24a56 filtros de pedimento completo 2026-03-27 08:17:29 -06:00
627d78f4b8 Merge pull request 'modificar formato de pedimento completo, el formato que le da el datastage no coincide con el adecuado' (#25) from formato-pedimento-completo into main
Reviewed-on: #25
2026-03-26 18:00:07 +00:00
Dulce
4c7eb22b28 modificar formato de pedimento completo, el formato que le da el datastage no coincide con el adecuado 2026-03-26 11:44:58 -06:00
30b6d73567 Merge pull request 'primeros 2 difitos' (#24) from pedimento-app-patente into main
Reviewed-on: #24
2026-03-23 22:29:34 +00:00
Dulce
460da47571 primeros 2 difitos 2026-03-23 15:39:37 -06:00
32aff7649e Merge pull request 'cambiar forma de agregar aduana al pedimento_app, ahora se incluyen siempre 3 digitos de aduana' (#23) from pedimento-app-patente into main
Reviewed-on: #23
2026-03-13 18:51:36 +00:00
Dulce
d115cdd072 cambiar forma de agregar aduana al pedimento_app, ahora se incluyen siempre 3 digitos de aduana 2026-03-13 11:57:38 -06:00
28d2eaedda Merge pull request 'nuevo enpoint en segundo plano' (#22) from efc-nuevos-cambios into main
Reviewed-on: #22
2026-03-09 21:35:45 +00:00
f2bf904c84 nuevo enpoint en segundo plano 2026-03-06 12:48:51 -07:00
271c562654 Merge pull request 'fix: se ajusta enpoint de bulk-create-pedimento_desk para scrapear el archivo de validacion.' (#21) from T2026-01-098 into main
Reviewed-on: #21
2026-02-09 19:49:02 +00:00
1c350cf2bf fix: se ajusta enpoint de bulk-create-pedimento_desk para scrapear el archivo de validacion. 2026-02-09 11:06:20 -07:00
e81a1aef4d Merge pull request 'Se ajusta validacion de existencia de pedimento asi como el registro correcto de la aduana, patente y pedimento' (#20) from T2025-12-100 into main
Reviewed-on: #20
2026-02-06 17:46:25 +00:00
eca519a789 Se ajusta validacion de existencia de pedimento asi como el registro correcto de la aduana, patente y pedimento 2026-02-06 10:26:11 -07:00
1dd05463c5 Merge pull request 'fix: Se ajusta codigo para generar el reporte de datastage condensado segun los campos seleccionados por el usuario/' (#19) from T2025-09-056 into main
Reviewed-on: #19
2026-02-05 16:09:03 +00:00
cbbcb3b323 Merge pull request 'T2026-01-032' (#18) from T2026-01-032 into main
Reviewed-on: #18
2026-02-05 16:08:21 +00:00
70999d413e fix: Se ajusta codigo para generar el reporte de datastage condensado segun los campos seleccionados por el usuario/ 2026-02-04 16:58:48 -07:00
fa518972ba fix: se agregan nuevos ajustes al filtro y ejecucion de procesos en base al filtro seleccionado. 2026-02-03 16:38:07 -07:00
6299c6f0fe fix: Filtrar procesos por organizacion dependiando del usuario, solo se debe mostrar todos cuando sea superusuario, en caso contrario solo lo que pertenezca al usuario. 2026-02-03 12:01:22 -07:00
67f339bd18 Merge pull request 'fix: se agrega nuevo endpoint para ejecutar el codigo de los comandos creados por kevin para procesdar las consultas a vucem.' (#17) from req--T2025-08-098 into main
Reviewed-on: #17
2026-02-03 17:54:28 +00:00
98331dae8f fix: se agrega nuevo endpoint para ejecutar el codigo de los comandos creados por kevin para procesdar las consultas a vucem. 2026-02-03 10:27:14 -07:00
6eaf6dc6d9 Merge pull request 'fix: se crea comando para ejecutar manualmente todos los pedimentos completos que aun no se hayan descargado por organizacion.' (#16) from fix-ejecucion-manual-proceso-pedimento-completo into main
Reviewed-on: #16
2026-01-30 14:00:57 +00:00
47 changed files with 7147 additions and 949 deletions

1
.gitignore vendored
View File

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

View File

@@ -157,7 +157,7 @@ class ViewPedimentoServicesUtilInformation(LoggingMixin, APIView, FiltroPorOrgan
# Si es importador de la organizacion, devuelve los servicios relacionados con sus pedimentos
if self.request.user.is_authenticated and self.request.user.groups.filter(name='importador').exists() and self.request.user.is_importador and self.request.user.groups.filter(name='user').exists():
return self.request.user.organizacion.procesamiento_pedimentos.filter(pedimento__contribuyente=self.request.user.rfc)
return self.request.user.organizacion.procesamiento_pedimentos.filter(pedimento__contribuyente__in=self.request.user.rfc.all())

View File

@@ -13,7 +13,7 @@ class CustomUserCreationForm(UserCreationForm):
class CustomUserChangeForm(UserChangeForm):
class Meta:
model = CustomUser
fields = ('username', 'email', 'first_name', 'last_name', 'organizacion', 'profile_picture')
fields = ('username', 'email', 'first_name', 'last_name', 'organizacion', 'profile_picture', 'is_importador', 'rfc')
class CustomUserAdmin(UserAdmin):
@@ -25,6 +25,7 @@ class CustomUserAdmin(UserAdmin):
list_filter = ('is_staff', 'is_active', 'organizacion')
search_fields = ('username', 'email', 'first_name', 'last_name')
ordering = ('username',)
filter_horizontal = ('rfc', 'groups', 'user_permissions')
# Fieldsets para editar un usuario
fieldsets = (

View File

@@ -12,7 +12,7 @@ class CustomUser(AbstractUser):
profile_picture = models.ImageField(upload_to='profile_pictures/', null=True, blank=True)
is_importador = models.BooleanField(default=False, help_text="Indicates if the user is an importer")
rfc = models.ForeignKey('customs.Importador', on_delete=models.SET_NULL, null=True, blank=True, related_name='users', help_text="RFC associated with the user if they are an importer")
rfc = models.ManyToManyField('customs.Importador', blank=True, related_name='users', help_text="RFCs de importadores asociados al usuario")
def __str__(self):
return self.username

View File

@@ -2,6 +2,7 @@
from rest_framework import serializers
from .models import CustomUser
from django.contrib.auth.models import Group
from api.customs.models import Importador
class CustomUserSerializer(serializers.ModelSerializer):
"""
@@ -10,8 +11,12 @@ class CustomUserSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True)
groups = serializers.PrimaryKeyRelatedField(queryset=Group.objects.all(), many=True, required=False)
rfc = serializers.CharField(max_length=20, required=False, allow_blank=True)
rfc = serializers.PrimaryKeyRelatedField(
queryset=Importador.objects.all(),
many=True,
required=False,
pk_field=serializers.CharField(),
)
class Meta:
model = CustomUser
@@ -20,10 +25,28 @@ class CustomUserSerializer(serializers.ModelSerializer):
def create(self, validated_data):
groups = validated_data.pop('groups', [])
rfcs = validated_data.pop('rfc', [])
password = validated_data.pop('password')
user = CustomUser(**validated_data)
user.set_password(password)
user.save()
if groups:
user.groups.set(groups)
if rfcs:
user.rfc.set(rfcs)
return user
def update(self, instance, validated_data):
groups = validated_data.pop('groups', None)
rfcs = validated_data.pop('rfc', None)
password = validated_data.pop('password', None)
for attr, value in validated_data.items():
setattr(instance, attr, value)
if password:
instance.set_password(password)
instance.save()
if groups is not None:
instance.groups.set(groups)
if rfcs is not None:
instance.rfc.set(rfcs)
return instance

View File

@@ -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")

View File

@@ -47,55 +47,31 @@ 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()
pedimento_app = str(obj.pedimento.pedimento_app).strip()
numero = str(obj.numero_partida).strip()
# Incluir pedimento_app en el patrón para evitar falsos positivos
# entre partidas con números cortos (1 matchearía 10, 100, etc.)
patron = f"vu_PT_{pedimento_app}_{numero}_"
# 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
# 17 = REQUEST partida, 18 = ERROR partida
qs = Document.objects.filter(
archivo=patron_exacto
)
pedimento=obj.pedimento,
archivo__icontains=patron,
).exclude(document_type_id__in=[17, 18])
# Opción 2: Si puede tener diferentes extensiones
# patron_base = f'documents/vu_PT_{pedimentoApp}_{numero}'
# qs = Document.objects.filter(
# archivo__startswith=patron_base
# ).filter(
# archivo__in=[
# f'{patron_base}.xml',
# f'{patron_base}.pdf',
# f'{patron_base}.zip'
# ]
# )
# Filtro adicional por pedimento si el modelo Document tiene este campo
if hasattr(Document, 'pedimento'):
qs = qs.filter(pedimento=obj.pedimento)
# Filtro por organización
if hasattr(obj, 'organizacion') and obj.organizacion:
qs = qs.filter(organizacion=obj.organizacion)
serializer = DocumentSerializer(qs, many=True, context=self.context)
return serializer.data
#return []
except Exception:
# En caso de cualquier error (por ejemplo, importaciones circulares), devolver lista vacía
return []
class Meta:
model = Partida
@@ -208,10 +184,11 @@ class EDocumentSerializer(serializers.ModelSerializer):
numero = str(obj.numero_edocument).strip()
# id_pedimento = str(obj.pedimento_id).strip()
# excluir e documents de tipo request y de tipo error
qs = Document.objects.filter(
pedimento=obj.pedimento,
archivo__icontains=numero,
)
).exclude(document_type_id__in=[21, 25])
# Filtro por organización si aplica
if hasattr(obj, 'organizacion') and obj.organizacion:
@@ -263,18 +240,23 @@ class CoveSerializer(serializers.ModelSerializer):
try:
numero = str(obj.numero_cove).strip()
# Excluir los tipo de documento 20, 24, 23 y 19
# 20 = error solicitud cove
# 24 = error solicitud acuse cove
# 23 = request acuse cove
# 19 = request cove
qs = Document.objects.filter(
pedimento=obj.pedimento,
archivo__icontains=numero,
)
).exclude(document_type_id__in=[20, 24, 23, 19])
# 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 []

View File

@@ -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
@@ -87,8 +90,11 @@ def trigger_celery_task_on_cove_create(sender, instance, created, **kwargs):
import logging
logger = logging.getLogger('api.customs.async_operations')
logger.info(f"Cove creado: {instance.id}, creando procesamiento...")
crear_procesamiento_cove.apply_async(args=[str(instance.pedimento.id)])
crear_procesamiento_acuse_cove.apply_async(args=[str(instance.pedimento.id)])
pedimento_id = str(instance.pedimento.id)
def enqueue_cove_tasks():
crear_procesamiento_cove.apply_async(args=[pedimento_id])
crear_procesamiento_acuse_cove.apply_async(args=[pedimento_id])
transaction.on_commit(enqueue_cove_tasks)
@receiver(post_save, sender=EDocument)
def trigger_celery_task_on_edocument_create(sender, instance, created, **kwargs):
@@ -96,5 +102,8 @@ def trigger_celery_task_on_edocument_create(sender, instance, created, **kwargs)
import logging
logger = logging.getLogger('api.customs.async_operations')
logger.info(f"EDocument creado: {instance.id}, creando procesamiento...")
crear_procesamiento_edocument.apply_async(args=[str(instance.pedimento.id)])
crear_procesamiento_acuse.apply_async(args=[str(instance.pedimento.id)])
pedimento_id = str(instance.pedimento.id)
def enqueue_edocument_tasks():
crear_procesamiento_edocument.apply_async(args=[pedimento_id])
crear_procesamiento_acuse.apply_async(args=[pedimento_id])
transaction.on_commit(enqueue_edocument_tasks)

View File

@@ -1,2 +1,4 @@
from .microservice import *
from .internal_services import *
from .internal_services import *
from .bulk_upload import *
from .microservice_v2 import *

View File

@@ -6,6 +6,8 @@ from api.customs.models import ProcesamientoPedimento, Pedimento, Cove, EDocumen
from core.utils import xml_controller
import requests
from core.utils import xml_remesas_controller
import logging
logger = logging.getLogger(__name__)
def obtener_pedimentos(organizacion_id):
return Pedimento.objects.filter(organizacion_id=organizacion_id)
@@ -35,23 +37,31 @@ def auditor_descargas(pedimento, servicio, related_name, variable, mensaje):
pedimento_id = pedimento.id
docs = getattr(pedimento, related_name).all()
print(f"pedimento: {pedimento}, servicio: {servicio}, related_name: {related_name}, variable: {variable}, mensaje: {mensaje}")
logger.info(f"pedimento: {pedimento}, servicio: {servicio}, related_name: {related_name}, variable: {variable}, mensaje: {mensaje}")
# Si no hay documentos, marcar como completado
if not docs.exists():
proceso = modificar_estado_procesamiento(pedimento, servicio_id=servicio, nuevo_estado=3) # Estado "completado"
print(f"✓ Pedimento {pedimento_id} no tiene {mensaje}s para procesar.")
logger.info(f"✓ Pedimento {pedimento_id} no tiene {mensaje}s para procesar.")
else:
all_docs = all(getattr(doc, variable) for doc in docs)
if all_docs:
proceso = modificar_estado_procesamiento(pedimento, servicio_id=servicio, nuevo_estado=3) # Estado "completado"
print(f"✓ Pedimento {pedimento_id} tiene todos sus {mensaje} descargados.")
logger.info(f"✓ Pedimento {pedimento_id} tiene todos sus {mensaje} descargados.")
else:
proceso = modificar_estado_procesamiento(pedimento, servicio_id=servicio, nuevo_estado=4) # Estado "en progreso"
print(f"✗ Pedimento {pedimento_id} NO tiene todos sus {mensaje} descargados.")
logger.info(f"✗ Pedimento {pedimento_id} NO tiene todos sus {mensaje} descargados.")
if proceso:
print(f"✓ Proceso de auditoría para pedimento {pedimento_id} completado.")
logger.info(f"✓ Proceso de auditoría para pedimento {pedimento_id} completado.")
else:
print(f"✗ No se encontró proceso de auditoría para pedimento {pedimento_id}.")
logger.info(f"✗ No se encontró proceso de auditoría para pedimento {pedimento_id}.")
## Auditar pedimentos
@@ -121,44 +131,66 @@ def auditar_procesamiento_remesa_por_pedimento(pedimento_id):
@shared_task
def crear_partidas(organizacion_id):
from api.customs.models import Partida
pedimentos = obtener_pedimentos(organizacion_id)
total_pedimentos = pedimentos.count()
pedimentos_procesados = 0
total_partidas_agregadas = 0
print(f"Iniciando procesamiento de {total_pedimentos} pedimentos para organización {organizacion_id}")
completados = []
con_pendientes = []
sin_datos = []
errores = []
for pedimento in pedimentos:
pedimentos_procesados += 1
partidas_agregadas_pedimento = 0
# Validar que numero_partidas no sea None y sea mayor que 0
if pedimento.numero_partidas is not None and pedimento.numero_partidas > 0:
partidas_existentes = pedimento.partidas.count()
if pedimento.numero_partidas > partidas_existentes:
print(f"Procesando pedimento {pedimento.id} ({pedimentos_procesados}/{total_pedimentos}) - Partidas existentes: {partidas_existentes}, Requeridas: {pedimento.numero_partidas}")
for i in range(1, pedimento.numero_partidas + 1):
from api.customs.models import Partida
partida, created = Partida.objects.get_or_create(
pedimento=pedimento,
numero_partida=i,
organizacion_id=organizacion_id
)
if created:
partidas_agregadas_pedimento += 1
total_partidas_agregadas += 1
print(f" → Partidas agregadas para pedimento {pedimento.id}: {partidas_agregadas_pedimento}")
else:
print(f"Pedimento {pedimento.id} ya tiene todas sus partidas ({partidas_existentes}/{pedimento.numero_partidas})")
else:
print(f"Pedimento {pedimento.id} omitido - numero_partidas: {pedimento.numero_partidas} (inválido)")
try:
if not pedimento.numero_partidas or pedimento.numero_partidas <= 0:
sin_datos.append({
'pedimento_id': str(pedimento.id),
'pedimento': pedimento.pedimento,
'razon': f'numero_partidas inválido ({pedimento.numero_partidas})',
})
continue
print(f"\n=== RESUMEN ===")
print(f"Pedimentos procesados: {pedimentos_procesados}")
print(f"Total de partidas agregadas: {total_partidas_agregadas}")
print(f"Procesamiento completado para organización {organizacion_id}")
for i in range(1, pedimento.numero_partidas + 1):
Partida.objects.get_or_create(
pedimento=pedimento,
numero_partida=i,
defaults={'organizacion_id': organizacion_id}
)
partidas = list(pedimento.partidas.order_by('numero_partida'))
no_descargadas = [p.numero_partida for p in partidas if not p.descargado]
if not no_descargadas:
completados.append(str(pedimento.id))
else:
con_pendientes.append({
'pedimento_id': str(pedimento.id),
'pedimento': pedimento.pedimento,
'total_partidas': len(partidas),
'descargadas': len(partidas) - len(no_descargadas),
'no_descargadas': no_descargadas,
})
except Exception as e:
errores.append({
'pedimento_id': str(pedimento.id),
'pedimento': pedimento.pedimento,
'error': str(e),
})
logger.error(f"Error creando partidas para pedimento {pedimento.id}: {e}")
return {
'organizacion_id': str(organizacion_id),
'total_pedimentos': total_pedimentos,
'completados': len(completados),
'con_pendientes': len(con_pendientes),
'sin_datos': len(sin_datos),
'con_errores': len(errores),
'detalle_pendientes': con_pendientes,
'detalle_sin_datos': sin_datos,
'detalle_errores': errores,
}
@shared_task
def crear_partidas_por_pedimento(pedimento_id):
@@ -169,6 +201,7 @@ def crear_partidas_por_pedimento(pedimento_id):
return
print(f"Procesando pedimento individual {pedimento_id}...")
logger.info(f"Procesando pedimento individual {pedimento_id}...")
partidas_agregadas = 0
# Validar que numero_partidas no sea None y sea mayor que 0
@@ -176,6 +209,7 @@ def crear_partidas_por_pedimento(pedimento_id):
partidas_existentes = pedimento.partidas.count()
if pedimento.numero_partidas > partidas_existentes:
print(f"Pedimento {pedimento_id} - Partidas existentes: {partidas_existentes}, Requeridas: {pedimento.numero_partidas}")
logger.info(f"Pedimento {pedimento_id} - Partidas existentes: {partidas_existentes}, Requeridas: {pedimento.numero_partidas}")
for i in range(1, pedimento.numero_partidas + 1):
from api.customs.models import Partida
@@ -188,62 +222,165 @@ def crear_partidas_por_pedimento(pedimento_id):
partidas_agregadas += 1
print(f"✓ Partidas agregadas para pedimento {pedimento_id}: {partidas_agregadas}")
logger.info(f"✓ Partidas agregadas para pedimento {pedimento_id}: {partidas_agregadas}")
else:
print(f"Pedimento {pedimento_id} ya tiene todas sus partidas ({partidas_existentes}/{pedimento.numero_partidas})")
logger.info(f"Pedimento {pedimento_id} ya tiene todas sus partidas ({partidas_existentes}/{pedimento.numero_partidas})")
else:
print(f"Error: Pedimento {pedimento_id} tiene numero_partidas inválido: {pedimento.numero_partidas}")
logger.info(f"Error: Pedimento {pedimento_id} tiene numero_partidas inválido: {pedimento.numero_partidas}")
# Auditar coves
def _auditar_organizacion(organizacion_id, servicio, related_name, variable, label):
"""
Itera todos los pedimentos de una organización auditando el campo `variable`
en la relación `related_name`. Retorna un resumen estructurado por pedimento.
"""
pedimentos = obtener_pedimentos(organizacion_id)
total_pedimentos = pedimentos.count()
completados = []
pendientes = []
errores = []
for pedimento in pedimentos:
try:
docs = list(getattr(pedimento, related_name).all())
total = len(docs)
faltantes = [
getattr(doc, 'numero_cove', None) or getattr(doc, 'numero_edocument', None)
for doc in docs if not getattr(doc, variable)
]
if total == 0 or len(faltantes) == 0:
nuevo_estado = 3
completados.append(str(pedimento.id))
else:
nuevo_estado = 4
pendientes.append({
'pedimento_id': str(pedimento.id),
'pedimento': pedimento.pedimento,
f'faltantes_{label}': faltantes,
'total': total,
'descargados': total - len(faltantes),
})
modificar_estado_procesamiento(pedimento, servicio_id=servicio, nuevo_estado=nuevo_estado)
except Exception as e:
errores.append({
'pedimento_id': str(pedimento.id),
'pedimento': pedimento.pedimento,
'error': str(e),
})
logger.error(f"Error auditando pedimento {pedimento.id} [{label}]: {e}")
return {
'organizacion_id': str(organizacion_id),
'auditoria': label,
'total_pedimentos': total_pedimentos,
'completados': len(completados),
'con_pendientes': len(pendientes),
'con_errores': len(errores),
'detalle_pendientes': pendientes,
'detalle_errores': errores,
}
@shared_task
def auditar_coves(organizacion_id):
for pedimento in obtener_pedimentos(organizacion_id):
auditor_descargas(
pedimento,
servicio=8,
related_name='coves',
variable='cove_descargado',
mensaje='COVE'
)
return _auditar_organizacion(
organizacion_id,
servicio=8,
related_name='coves',
variable='cove_descargado',
label='cove',
)
@shared_task
def auditar_acuse_cove(organizacion_id):
for pedimento in obtener_pedimentos(organizacion_id):
auditor_descargas(
pedimento,
servicio=9,
related_name='coves',
variable='acuse_cove_descargado',
mensaje='acuse de COVE'
)
return _auditar_organizacion(
organizacion_id,
servicio=9,
related_name='coves',
variable='acuse_cove_descargado',
label='acuse_cove',
)
# Revisa si el pedimento completo todos sus acuse coves
# Auditar edocuments
@shared_task
def auditar_edocuments(organizacion_id):
for pedimento in obtener_pedimentos(organizacion_id):
auditor_descargas(
pedimento,
servicio=7,
related_name='documentos',
variable='edocument_descargado',
mensaje='EDocument'
)
return _auditar_organizacion(
organizacion_id,
servicio=7,
related_name='documentos',
variable='edocument_descargado',
label='edocument',
)
@shared_task
def auditar_acuse(organizacion_id):
for pedimento in obtener_pedimentos(organizacion_id):
auditor_descargas(
pedimento,
servicio=6,
related_name='documentos',
variable='acuse_descargado',
mensaje='acuse'
)
return _auditar_organizacion(
organizacion_id,
servicio=6,
related_name='documentos',
variable='acuse_descargado',
label='acuse',
)
@shared_task
def auditar_remesas(organizacion_id):
"""
Audita el estado de descarga de remesas para todos los pedimentos de una organización.
A diferencia de coves/edocuments, las remesas no tienen campo booleano propio —
se verifica la existencia de un documento de tipo 3 (Remesa) en el pedimento.
"""
pedimentos = obtener_pedimentos(organizacion_id)
total_pedimentos = pedimentos.count()
completados = []
pendientes = []
errores = []
for pedimento in pedimentos:
try:
if not pedimento.remesas:
# El pedimento no declara remesas — no aplica, marcar como completado
modificar_estado_procesamiento(pedimento, servicio_id=5, nuevo_estado=3)
completados.append(str(pedimento.id))
elif pedimento.documents.filter(document_type=3).exists():
# Documento de remesa ya descargado
modificar_estado_procesamiento(pedimento, servicio_id=5, nuevo_estado=3)
completados.append(str(pedimento.id))
else:
# Tiene remesas declaradas pero el documento aún no existe
modificar_estado_procesamiento(pedimento, servicio_id=5, nuevo_estado=4)
pendientes.append({
'pedimento_id': str(pedimento.id),
'pedimento': pedimento.pedimento,
})
except Exception as e:
errores.append({
'pedimento_id': str(pedimento.id),
'pedimento': pedimento.pedimento,
'error': str(e),
})
logger.error(f"Error auditando remesa de pedimento {pedimento.id}: {e}")
return {
'organizacion_id': str(organizacion_id),
'auditoria': 'remesa',
'total_pedimentos': total_pedimentos,
'completados': len(completados),
'con_pendientes': len(pendientes),
'con_errores': len(errores),
'detalle_pendientes': pendientes,
'detalle_errores': errores,
}
@shared_task
def auditar_cove_por_pedimento(pedimento_id):
try:
print(f"auditar_cove_por_pedimento >>>> {pedimento_id}")
logger.info(f"auditar_cove_por_pedimento >>>> {pedimento_id}")
from api.customs.models import Pedimento
pedimento = Pedimento.objects.get(id=pedimento_id)
auditor_descargas(

View File

@@ -1,6 +1,8 @@
# auditoria_xml.py
import xml.etree.ElementTree as ET
from datetime import datetime
import logging
logger = logging.getLogger('api.customs.auditoria_xml')
def extraer_info_pedimento_xml(xml_content):
"""
@@ -13,8 +15,10 @@ def extraer_info_pedimento_xml(xml_content):
# Buscar el namespace (puede variar)
namespaces = {
'S': 'http://schemas.xmlsoap.org/soap/envelope/',
's': 'http://schemas.xmlsoap.org/soap/envelope/',
'ns2': 'http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/consultarpedimentocompleto',
'ns3': 'http://www.ventanillaunica.gob.mx/common/ws/oxml/respuesta'
'ns3': 'http://www.ventanillaunica.gob.mx/common/ws/oxml/respuesta',
}
resultado = {}
@@ -181,10 +185,37 @@ def extraer_info_pedimento_xml(xml_content):
if edocs_encontrados:
resultado['edocuments_en_xml'] = edocs_encontrados
# Verificar si hay error en la respuesta
# Verificar si hay error en la respuesta — 3 variantes según el servicio VUCEM:
# 1) Remesas/pedimentos: <ns3:tieneError> en namespace oxml/respuesta
# 2) eDocuments: <TieneError> en namespace tempuri.org, mensaje en <Errores>
# 3) Acuses: <error> sin namespace dentro de responseConsultaAcuses
tiene_error = root.find('.//ns3:tieneError', namespaces)
if tiene_error is not None:
resultado['tiene_error'] = tiene_error.text.lower() == 'true'
if resultado['tiene_error']:
mensaje = root.find('.//ns3:error/ns3:mensaje', namespaces)
if mensaje is not None and mensaje.text:
resultado['error_mensaje'] = mensaje.text.strip()
else:
# Variante eDocuments (tempuri.org)
tiene_error_edoc = root.find('.//{http://tempuri.org/}TieneError')
if tiene_error_edoc is not None:
resultado['tiene_error'] = tiene_error_edoc.text.lower() == 'true'
if resultado['tiene_error']:
errores_elem = root.find('.//{http://tempuri.org/}Errores')
if errores_elem is not None and errores_elem.text:
resultado['error_mensaje'] = errores_elem.text.strip()
else:
# Variante acuses: <error> sin namespace
error_acuses = root.find('.//error')
if error_acuses is not None and error_acuses.text is not None:
resultado['tiene_error'] = error_acuses.text.lower() == 'true'
if resultado['tiene_error']:
descripciones = root.findall('.//mensajeErrores/descripcion')
if descripciones:
resultado['error_mensaje'] = ' | '.join(
d.text.strip() for d in descripciones if d.text
)
return resultado

View File

@@ -0,0 +1,710 @@
from celery import shared_task
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 extract_django_suffix(filename):
"""
Extrae el sufijo UUID de 8 chars que storage_service añade a los archivos.
"""
name_without_ext = os.path.splitext(filename)[0]
match = re.search(r'_([a-zA-Z0-9]{8})$', name_without_ext)
if match:
return match.group(1)
return None
def get_clean_base_filename(filename):
"""
Obtiene el nombre base limpio sin el sufijo UUID de storage_service.
"""
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[:-9] # elimina _XXXXXXXX (underscore + 8 chars UUID)
else:
base_name = name_without_ext
base_name = re.sub(r'(_copy|_copia|_-_copia|_-_copy)(_\d+)?$', '', base_name)
return base_name.lower().strip('_')
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)
# 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:
updated_pedimentos.append({
"id": str(existing_pedimento.id),
"pedimento_app": existing_pedimento.pedimento_app,
"accion": "Documento ya existente, omitido",
"documento": file_name
})
else:
# Crear registro sin archivo primero
document = Document.objects.create(
organizacion=organizacion,
pedimento_id=existing_pedimento.id,
document_type=document_type,
fuente_id=fuente.id,
size=len(file_content),
extension=os.path.splitext(file_name)[1].lower().lstrip('.')
)
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'
}
)
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({
"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}")

View File

@@ -1,6 +1,14 @@
from celery import shared_task, group
from api.customs.models import ProcesamientoPedimento, Pedimento, Cove, EDocument
from core.utils import xml_controller
from api.customs.tasks.microservice import (
procesar_cove_individual,
procesar_acuse_individual,
procesar_acuse_cove_individual,
procesar_edoc_individual,
procesar_partida_individual,
procesar_remesa_individual,
)
@shared_task
def crear_procesamiento_remesa(pedimento_id):
@@ -11,7 +19,7 @@ def crear_procesamiento_remesa(pedimento_id):
if pedimento.remesas:
existe = ProcesamientoPedimento.objects.filter(
pedimento=pedimento,
servicio_id=5, # ID del servicio de remesas
servicio_id=5,
organizacion=pedimento.organizacion,
estado_id__in=[1, 2, 3, 4]
).exists()
@@ -19,10 +27,11 @@ def crear_procesamiento_remesa(pedimento_id):
logger.info(f"[TAREA] ProcesamientoPedimento remesa creado para pedimento {pedimento_id}")
ProcesamientoPedimento.objects.create(
pedimento=pedimento,
estado_id=1, # Estado "pendiente"
estado_id=1,
servicio_id=5,
organizacion=pedimento.organizacion
)
procesar_remesa_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)])
@shared_task
def crear_procesamiento_partida(pedimento_id):
@@ -32,7 +41,7 @@ def crear_procesamiento_partida(pedimento_id):
logger.info(f"[TAREA] crear_procesamiento_partida para pedimento {pedimento_id}")
existe = ProcesamientoPedimento.objects.filter(
pedimento=pedimento,
servicio_id=4, # ID del servicio de partidas
servicio_id=4,
organizacion=pedimento.organizacion,
estado_id__in=[1, 2, 3, 4]
).exists()
@@ -40,10 +49,11 @@ def crear_procesamiento_partida(pedimento_id):
logger.info(f"[TAREA] ProcesamientoPedimento partida creado para pedimento {pedimento_id}")
ProcesamientoPedimento.objects.create(
pedimento=pedimento,
estado_id=1, # Estado "pendiente"
estado_id=1,
servicio_id=4,
organizacion=pedimento.organizacion
)
procesar_partida_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)])
@shared_task
def crear_procesamiento_cove(pedimento_id):
@@ -54,7 +64,7 @@ def crear_procesamiento_cove(pedimento_id):
if pedimento.coves.exists():
existe = ProcesamientoPedimento.objects.filter(
pedimento=pedimento,
servicio_id=8, # ID del servicio de Coves
servicio_id=8,
organizacion=pedimento.organizacion,
estado_id__in=[1, 2, 3, 4]
).exists()
@@ -62,10 +72,11 @@ def crear_procesamiento_cove(pedimento_id):
logger.info(f"[TAREA] ProcesamientoPedimento cove creado para pedimento {pedimento_id}")
ProcesamientoPedimento.objects.create(
pedimento=pedimento,
estado_id=1, # Estado "pendiente"
estado_id=1,
servicio_id=8,
organizacion=pedimento.organizacion
)
procesar_cove_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)])
@shared_task
def crear_procesamiento_acuse(pedimento_id):
@@ -73,10 +84,10 @@ def crear_procesamiento_acuse(pedimento_id):
logger = logging.getLogger('api.customs.async_operations')
pedimento = Pedimento.objects.get(id=pedimento_id)
logger.info(f"[TAREA] crear_procesamiento_acuse para pedimento {pedimento_id}")
if pedimento.coves.exists():
if pedimento.documentos.exists():
existe = ProcesamientoPedimento.objects.filter(
pedimento=pedimento,
servicio_id=6, # ID del servicio de Acuse Cove
servicio_id=6,
organizacion=pedimento.organizacion,
estado_id__in=[1, 2, 3, 4]
).exists()
@@ -84,10 +95,11 @@ def crear_procesamiento_acuse(pedimento_id):
logger.info(f"[TAREA] ProcesamientoPedimento acuse creado para pedimento {pedimento_id}")
ProcesamientoPedimento.objects.create(
pedimento=pedimento,
estado_id=1, # Estado "pendiente"
estado_id=1,
servicio_id=6,
organizacion=pedimento.organizacion
)
procesar_acuse_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)])
@shared_task
def crear_procesamiento_acuse_cove(pedimento_id):
@@ -98,7 +110,7 @@ def crear_procesamiento_acuse_cove(pedimento_id):
if pedimento.coves.exists():
existe = ProcesamientoPedimento.objects.filter(
pedimento=pedimento,
servicio_id=9, # ID del servicio de Acuse Cove
servicio_id=9,
organizacion=pedimento.organizacion,
estado_id__in=[1, 2, 3, 4]
).exists()
@@ -106,10 +118,11 @@ def crear_procesamiento_acuse_cove(pedimento_id):
logger.info(f"[TAREA] ProcesamientoPedimento acuse_cove creado para pedimento {pedimento_id}")
ProcesamientoPedimento.objects.create(
pedimento=pedimento,
estado_id=1, # Estado "pendiente"
estado_id=1,
servicio_id=9,
organizacion=pedimento.organizacion
)
procesar_acuse_cove_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)])
@shared_task
def crear_procesamiento_edocument(pedimento_id):
@@ -120,7 +133,7 @@ def crear_procesamiento_edocument(pedimento_id):
if pedimento.documentos.exists():
existe = ProcesamientoPedimento.objects.filter(
pedimento=pedimento,
servicio_id=7, # ID del servicio de EDocument
servicio_id=7,
organizacion=pedimento.organizacion,
estado_id__in=[1, 2, 3, 4]
).exists()
@@ -128,10 +141,11 @@ def crear_procesamiento_edocument(pedimento_id):
logger.info(f"[TAREA] ProcesamientoPedimento edocument creado para pedimento {pedimento_id}")
ProcesamientoPedimento.objects.create(
pedimento=pedimento,
estado_id=1, # Estado "pendiente"
estado_id=1,
servicio_id=7,
organizacion=pedimento.organizacion
)
procesar_edoc_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)])
@shared_task
def crear_procesamiento_pedimento_completo(organizacion_id):

View File

@@ -1,3 +1,4 @@
from api.organization.models import Organizacion
from celery import group
from celery import shared_task, group
from api.customs.models import *
@@ -8,17 +9,37 @@ import requests
from config.settings import SERVICE_API_URL_V2
from datetime import datetime
import json
import logging
import uuid
# este solo fue para pruebas personales, lo dejo por si en un futuro lo requiero
TEST_ORG_ID = uuid.UUID('defc7848-4f39-4d67-9dba-5bb445248d23')
logger = logging.getLogger('api.customs.microservice_v2')
def credenciales_to_dict(credenciales):
if not credenciales:
return {}
key_value = None
if credenciales.key:
if hasattr(credenciales.key, 'url'):
key_value = credenciales.key.url
else:
key_value = str(credenciales.key)
cer_value = None
if credenciales.cer:
if hasattr(credenciales.cer, 'url'):
cer_value = credenciales.cer.url
else:
cer_value = str(credenciales.cer)
return {
"id": str(credenciales.id),
"user": credenciales.usuario,
"password": credenciales.password,
"efirma": credenciales.efirma,
"key": credenciales.key.url if credenciales.key else None,
"cer": credenciales.cer.url if credenciales.cer else None,
"key": key_value,
"cer": cer_value,
"is_active": credenciales.is_active,
"organizacion": str(credenciales.organizacion.id) if credenciales.organizacion else None,
}
@@ -117,7 +138,7 @@ def procesar_edocs_pedimento(pedimento_id):
}
response = requests.post(
f"{SERVICE_API_URL_V2}/services/download/edoc/",
f"{SERVICE_API_URL_V2}/services/download/all/edocs/",
data=json.dumps(payload),
headers={"Content-Type": "application/json"}
)
@@ -217,10 +238,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 +266,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
@@ -244,27 +283,40 @@ def procesar_remesas(organizacion_id):
pedimentos = Pedimento.objects.filter(organizacion_id=organizacion_id)
for pedimento in pedimentos:
if not pedimento.documents.filter(document_type=3).exists(): # Tipo 3: Remesa
# Convertir el pedimento a JSON usando el serializer
logger.info(f"pedimento >>>> {pedimento}")
try:
# if pedimento.documents.filter(document_type=3).exists(): # Remesa ya descargada
# logger.info(f"Pedimento {pedimento.pedimento} ya tiene remesa descargada, omitiendo.")
# continue
pedimento_dict = pedimento_to_dict(pedimento)
credenciales = Vucem.objects.filter(id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id).first()
credencial_importador = CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first()
if not credencial_importador:
logger.warning(f"Sin credenciales para RFC {pedimento.contribuyente} (pedimento {pedimento.pedimento}), omitiendo.")
continue
credenciales = Vucem.objects.filter(id=credencial_importador.vucem.id).first()
if not credenciales:
logger.warning(f"Credencial Vucem no encontrada para pedimento {pedimento.pedimento}, omitiendo.")
continue
credenciales_dict = credenciales_to_dict(credenciales)
payload = {
"pedimento": pedimento_dict,
"credencial": credenciales_dict
}
response = requests.post(
f"{SERVICE_API_URL_V2}/services/remesas",
f"{SERVICE_API_URL_V2}/services/remesas/",
data=json.dumps(payload),
headers={"Content-Type": "application/json"}
)
# Aquí puedes continuar con el resto de tu lógica
logger.info(f"Servicio enviado para pedimento {pedimento.pedimento} — status {response.status_code}")
print(f"Servicio enviado para pedimento {pedimento.pedimento}")
except Exception as e:
logger.error(f"Error procesando remesa para pedimento {pedimento.pedimento}: {e}", exc_info=True)
@shared_task
def procesar_coves(organizacion_id):
@@ -431,7 +483,7 @@ def documentos_con_errores(organizacion_id):
@shared_task
def procesar_procesamiento_pedimento(organizacion_id):
print("Creando procesamientos de pedimentos para organización:", organizacion_id)
# print("Creando procesamientos de pedimentos para organización:", organizacion_id)
pedimentos = Pedimento.objects.filter(organizacion_id=organizacion_id)
# pedimentos = Pedimento.objects.filter(id='1c061182-ac68-45b0-b3d7-35bf2264982b')
@@ -454,7 +506,7 @@ def procesar_procesamiento_pedimento(organizacion_id):
, servicio_id=3
, tipo_procesamiento_id=2) # servicio 3: Pedimento Completo
print("Procesamiento creado para pedimento:", pedimento.pedimento_app)
# print("Procesamiento creado para pedimento:", pedimento.pedimento_app)
procesar_pedimentos_completos.delay(organizacion_id)
@@ -477,8 +529,8 @@ def ejecutar_por_organizacion_y_procesamiento(organizacion_id, procesamiento):
procesar_procesamiento_pedimento.delay(organizacion_id)
else:
# Procesamiento no reconocido
print(f"Procesamiento no reconocido: {procesamiento}")
# pass
# print(f"Procesamiento no reconocido: {procesamiento}")
pass
def ejecutar_todos_por_organizacion(organizacion_id):
procesar_coves.delay(organizacion_id)
@@ -489,6 +541,37 @@ def ejecutar_todos_por_organizacion(organizacion_id):
procesar_pedimentos_completos.delay(organizacion_id)
procesar_remesas.delay(organizacion_id)
def ejecutar_basicos_organizacion(organizacion_id):
# solo coves y e documents, si es necesario ya en un futuro se agregan los de partidas, pedimento completo y esas madres
procesar_coves.delay(organizacion_id)
procesar_acuse_coves.delay(organizacion_id)
procesar_edocs.delay(organizacion_id)
procesar_acuses.delay(organizacion_id)
# procesar_partidas.delay(organizacion_id)
# procesar_pedimentos_completos.delay(organizacion_id)
# procesar_remesas.delay(organizacion_id)
@shared_task
def process_organization_batch(org_id):
"""
Procesa todos los tipos de documentos pendientes para una organización.
"""
ejecutar_basicos_organizacion(org_id)
@shared_task
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,
apply_auto_download=True,
)
for org in active_orgs:
process_organization_batch.apply_async(
args=[str(org.id)],
queue='org_processing'
)
return f"Dispatched {active_orgs.count()} organizations"

View File

@@ -3,7 +3,12 @@ from django.urls import reverse
from rest_framework.test import APITestCase, APIClient
from rest_framework import status
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from unittest.mock import patch
from io import BytesIO
import zipfile
from api.organization.models import Organizacion
from api.licence.models import Licencia
from .models import Pedimento, TipoOperacion, ProcesamientoPedimento, EDocument
User = get_user_model()
@@ -75,3 +80,147 @@ class CustomsViewsTests(APITestCase):
self.client.force_authenticate(user=self.admin)
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
# ---------------------------------------------------------------------------
# Tests de integración para bulk-create (ViewSetPedimento.bulk_create)
# Verifica que al re-cargar un pedimento existente sus documentos se actualicen
# ---------------------------------------------------------------------------
class BulkCreateDocumentReplaceTests(APITestCase):
"""Verifica que bulk-create actualiza los documentos de pedimentos existentes
en vez de ignorarlos, y que no quedan archivos residuales en el storage."""
PEDIMENTO_APP = "24-01-3420-1234567"
def setUp(self):
self.licencia = Licencia.objects.create(nombre="Lic100GB", almacenamiento=100)
self.org = Organizacion.objects.create(
nombre="OrgBulkCreate",
licencia=self.licencia,
is_active=True,
is_verified=True,
)
self.user = User.objects.create_user(
username="bulkcreateuser", password="pass", organizacion=self.org
)
self.pedimento = Pedimento.objects.create(
organizacion=self.org,
pedimento="1234567",
pedimento_app=self.PEDIMENTO_APP,
)
from api.record.models import DocumentType, Fuente
self.doc_type = DocumentType.objects.get_or_create(nombre="Pedimento")[0]
# bulk_create usa fuente_id=4 hardcodeado; debe existir en la DB de test
Fuente.objects.get_or_create(id=4, defaults={"nombre": "Bulk Create"})
self.url = reverse("Pedimento-bulk-create")
self.client.force_authenticate(user=self.user)
def _make_zip(self, files_dict):
"""Crea un ZIP en memoria. files_dict = {nombre_archivo: contenido_bytes}"""
buf = BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
for name, content in files_dict.items():
zf.writestr(name, content)
buf.seek(0)
return SimpleUploadedFile(
f"{self.PEDIMENTO_APP}.zip", buf.read(), content_type="application/zip"
)
def _post_zip(self, files_dict):
return self.client.post(
self.url,
{"contribuyente": "XAXX010101000", "archivos": [self._make_zip(files_dict)]},
format="multipart",
)
@patch("api.customs.views.storage_service")
def test_existing_pedimento_not_duplicated(self, mock_st):
"""Re-subir un pedimento existente NO debe crear un segundo Pedimento."""
mock_st.save_document_from_path.return_value = "org_1/documents/ped/informe_a1b2c3d4.pdf"
self._post_zip({"informe.pdf": b"contenido"})
self.assertEqual(
Pedimento.objects.filter(
organizacion=self.org, pedimento_app=self.PEDIMENTO_APP
).count(),
1,
)
@patch("api.customs.views.storage_service")
def test_existing_pedimento_document_replaced_not_duplicated(self, mock_st):
"""Documento existente con el mismo nombre base se reemplaza, no se duplica."""
from api.record.models import Document
old_path = f"org_1/documents/{self.PEDIMENTO_APP}/informe_a1b2c3d4.pdf"
old_doc = Document.objects.create(
organizacion=self.org,
pedimento=self.pedimento,
document_type=self.doc_type,
archivo=old_path,
size=500,
extension="pdf",
)
new_path = f"org_1/documents/{self.PEDIMENTO_APP}/informe_b5c6d7e8.pdf"
mock_st.save_document_from_path.return_value = new_path
mock_st.delete_file.return_value = True
self._post_zip({"informe.pdf": b"contenido actualizado"})
docs = Document.objects.filter(pedimento=self.pedimento)
# Sin duplicados
self.assertEqual(docs.count(), 1)
# Mismo registro
self.assertEqual(docs.first().id, old_doc.id)
# Archivo actualizado
old_doc.refresh_from_db()
self.assertEqual(old_doc.archivo.name, new_path)
@patch("api.customs.views.storage_service")
def test_existing_pedimento_stale_file_deleted_from_storage(self, mock_st):
"""Al reemplazar un documento, el archivo viejo debe eliminarse del storage."""
from api.record.models import Document
old_path = f"org_1/documents/{self.PEDIMENTO_APP}/informe_a1b2c3d4.pdf"
Document.objects.create(
organizacion=self.org,
pedimento=self.pedimento,
document_type=self.doc_type,
archivo=old_path,
size=500,
extension="pdf",
)
mock_st.save_document_from_path.return_value = f"org_1/documents/{self.PEDIMENTO_APP}/informe_b5c6d7e8.pdf"
mock_st.delete_file.return_value = True
self._post_zip({"informe.pdf": b"contenido"})
# delete_file debe haberse llamado con la ruta del archivo viejo
mock_st.delete_file.assert_called()
called_arg = str(mock_st.delete_file.call_args[0][0])
self.assertIn("informe_a1b2c3d4", called_arg)
@patch("api.customs.views.storage_service")
def test_existing_pedimento_new_file_added(self, mock_st):
"""Archivo nuevo en el ZIP se añade al pedimento existente."""
from api.record.models import Document
mock_st.save_document_from_path.return_value = "org_1/documents/ped/nuevo_b5c6d7e8.pdf"
self._post_zip({"nuevo_documento.pdf": b"contenido nuevo"})
self.assertGreaterEqual(
Document.objects.filter(pedimento=self.pedimento).count(), 1
)
@patch("api.customs.views.storage_service")
def test_already_existing_count_in_response(self, mock_st):
"""La respuesta debe indicar que el pedimento ya existía (already_existing_count >= 1)."""
mock_st.save_document_from_path.return_value = "org_1/documents/ped/f_a1b2c3d4.pdf"
response = self._post_zip({"archivo.pdf": b"contenido"})
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_207_MULTI_STATUS, status.HTTP_201_CREATED])
data = response.json()
self.assertGreaterEqual(data.get("already_existing_count", 0), 1)

View File

@@ -10,7 +10,8 @@ from .views import (
ViewSetEDocument,
ViewSetCove,
ImportadorViewSet,
PartidaViewSet
PartidaViewSet,
EjecutarComandoView
)
# from .views import YourViewSet # Import your viewsets here
@@ -38,6 +39,7 @@ from .views_auditor import (
auditar_acuse_cove_endpoint,
auditar_edocuments_endpoint,
auditar_acuse_endpoint,
auditar_remesas_endpoint,
auditar_cove_pedimento_endpoint,
auditar_acuse_cove_pedimento_endpoint,
auditar_edocument_pedimento_endpoint,
@@ -71,6 +73,7 @@ urlpatterns = [
path('auditor/auditar-acuse-cove/', auditar_acuse_cove_endpoint, name='auditar-acuse-cove'),
path('auditor/auditar-edocuments/', auditar_edocuments_endpoint, name='auditar-edocuments'),
path('auditor/auditar-acuse/', auditar_acuse_endpoint, name='auditar-acuse'),
path('auditor/auditar-remesas/', auditar_remesas_endpoint, name='auditar-remesas'),
path('auditor/auditar-cove/pedimento/', auditar_cove_pedimento_endpoint, name='auditar-cove-pedimento'),
path('auditor/auditar-acuse-cove/pedimento/', auditar_acuse_cove_pedimento_endpoint, name='auditar-acuse-cove-pedimento'),
path('auditor/auditar-edocument/pedimento/', auditar_edocument_pedimento_endpoint, name='auditar-edocument-pedimento'),
@@ -95,4 +98,7 @@ urlpatterns = [
path('auditor/obtener-respuesta/acuse-cove-vu/', auditor_obtener_respuesta_acuse_cove_vu, name='obtener-respuesta-acuse-cove-vu'),
path('auditor/obtener-peticion/edocument-vu/', auditor_obtener_peticion_edocument_vu, name='obtener-peticion-edocument-vu'),
path('auditor/obtener-respuesta/edocument-vu/', auditor_obtener_respuesta_edocument_vu, name='obtener-respuesta-edocument-vu'),
path('procesamientopedimentos-ejecutar-comando/', EjecutarComandoView.as_view(), name='procesamientopedimentos-ejecutar-comando'),
]

File diff suppressed because it is too large Load Diff

View File

@@ -8,28 +8,68 @@ from drf_yasg import openapi
from core.permissions import IsSuperUser, IsSameOrganizationDeveloper
from .tasks.auditoria import (
crear_partidas,
crear_partidas_por_pedimento,
auditar_procesamiento_remesa_por_pedimento,
auditar_coves,
auditar_acuse_cove,
auditar_edocuments,
auditar_acuse,
auditar_cove_por_pedimento,
auditar_acuse_cove_por_pedimento,
auditar_edocument_por_pedimento,
auditar_acuse_por_pedimento
auditar_remesas,
)
from .tasks.internal_services import auditar_pedimentos
from .tasks.microservice_v2 import procesar_pedimentos_completos
from api.customs.models import Pedimento
from api.organization.models import Organizacion
from api.record.models import Document
from .tasks.auditoria import auditar_pedimento_por_id
from .tasks.auditoria_xml import extraer_info_pedimento_xml
import tempfile
import os
from api.utils.storage_service import storage_service
import logging
import uuid
logger = logging.getLogger('api.customs.views_auditor')
def get_document_content(documento):
"""
Obtiene el contenido de un documento (MinIO o local).
Retorna el contenido como string o bytes.
"""
ruta = str(documento.archivo)
with tempfile.NamedTemporaryFile(delete=False) as tmp:
tmp_path = tmp.name
try:
success = storage_service.download_file(ruta, tmp_path)
if not success:
return None
with open(tmp_path, 'r', encoding='utf-8', errors='ignore') as f:
content = f.read()
return content
finally:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
def get_document_path(documento):
"""
Obtiene la ruta temporal de un documento para lectura.
Retorna la ruta del archivo temporal descargado.
"""
ruta = str(documento.archivo)
tmp = tempfile.NamedTemporaryFile(delete=False)
tmp_path = tmp.name
tmp.close()
success = storage_service.download_file(ruta, tmp_path)
if not success:
return None
return tmp_path
@swagger_auto_schema(
method='post',
operation_description="Crea partidas para todos los pedimentos de una organización",
operation_description="Crea partidas faltantes para todos los pedimentos de una organización e informa cuáles están descargadas",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
@@ -38,7 +78,7 @@ from .tasks.auditoria_xml import extraer_info_pedimento_xml
required=['organizacion_id']
),
responses={
200: openapi.Response('Tarea iniciada correctamente'),
202: openapi.Response('Tarea iniciada — usar task_id para consultar resultado'),
400: openapi.Response('Error en los parámetros'),
403: openapi.Response('No tiene permisos suficientes')
}
@@ -46,37 +86,27 @@ from .tasks.auditoria_xml import extraer_info_pedimento_xml
@api_view(['POST'])
@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)])
def crear_partidas_organizacion(request):
"""
Crea partidas para todos los pedimentos de una organización específica.
"""
organizacion_id = request.data.get('organizacion_id')
if not organizacion_id:
return Response(
{'error': 'Debe proporcionar organizacion_id'},
status=status.HTTP_400_BAD_REQUEST
)
return Response({'error': 'Debe proporcionar organizacion_id'}, status=status.HTTP_400_BAD_REQUEST)
# Validar permisos
user = request.user
if not user.is_superuser and str(user.organizacion.id) != organizacion_id:
return Response(
{'error': 'No tiene permisos para esta organización'},
status=status.HTTP_403_FORBIDDEN
)
return Response({'error': 'No tiene permisos para esta organización'}, status=status.HTTP_403_FORBIDDEN)
# Ejecutar la tarea
task = crear_partidas.delay(organizacion_id)
message = f"Creación de partidas iniciada para la organización {organizacion_id}"
return Response({
'message': message,
'task_id': task.id
}, status=status.HTTP_200_OK)
'organizacion_id': organizacion_id,
'auditoria': 'partidas',
'task_id': task.id,
'mensaje': f'Creación de partidas iniciada. Consulta el resultado en GET /api/tasks/status/{task.id}/',
}, status=status.HTTP_202_ACCEPTED)
@swagger_auto_schema(
method='post',
operation_description="Crea partidas para un pedimento específico",
operation_description="Crea partidas faltantes para un pedimento e informa cuáles están descargadas",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
@@ -85,48 +115,74 @@ def crear_partidas_organizacion(request):
required=['pedimento_id']
),
responses={
200: openapi.Response('Tarea iniciada correctamente'),
200: openapi.Response('Resultado de creación y estado de descarga de partidas'),
400: openapi.Response('Error en los parámetros'),
403: openapi.Response('No tiene permisos suficientes'),
404: openapi.Response('Pedimento no encontrado')
}
)
@api_view(['POST'])
@permission_classes([IsAuthenticated ])
@permission_classes([IsAuthenticated])
def crear_partidas_pedimento(request):
"""
Crea partidas para un pedimento específico.
"""
pedimento_id = request.data.get('pedimento_id')
if not pedimento_id:
return Response(
{'error': 'Debe proporcionar pedimento_id'},
status=status.HTTP_400_BAD_REQUEST
)
return Response({'error': 'Debe proporcionar pedimento_id'}, status=status.HTTP_400_BAD_REQUEST)
# Validar permisos y existencia del pedimento
try:
pedimento = Pedimento.objects.get(id=pedimento_id)
user = request.user
if not user.is_superuser and str(pedimento.organizacion.id) != str(user.organizacion.id):
return Response(
{'error': 'No tiene permisos para este pedimento'},
status=status.HTTP_403_FORBIDDEN
)
pedimento = Pedimento.objects.prefetch_related('partidas').select_related('organizacion').get(id=pedimento_id)
except Pedimento.DoesNotExist:
return Response(
{'error': 'Pedimento no encontrado'},
status=status.HTTP_404_NOT_FOUND
)
return Response({'error': 'Pedimento no encontrado'}, status=status.HTTP_404_NOT_FOUND)
# Ejecutar la tarea
task = crear_partidas_por_pedimento.delay(pedimento_id)
message = f"Creación de partidas iniciada para el pedimento {pedimento_id}"
user = request.user
if not user.is_superuser and str(pedimento.organizacion.id) != str(user.organizacion.id):
return Response({'error': 'No tiene permisos para este pedimento'}, status=status.HTTP_403_FORBIDDEN)
if not pedimento.numero_partidas or pedimento.numero_partidas <= 0:
return Response({
'pedimento_id': str(pedimento_id),
'pedimento': pedimento.pedimento,
'estado': 'sin_datos',
'mensaje': f'El pedimento no tiene número de partidas definido (numero_partidas={pedimento.numero_partidas})',
}, status=status.HTTP_200_OK)
# Crear partidas faltantes (get_or_create por número)
from api.customs.models import Partida
partidas_creadas = 0
for i in range(1, pedimento.numero_partidas + 1):
_, created = Partida.objects.get_or_create(
pedimento=pedimento,
numero_partida=i,
defaults={'organizacion_id': pedimento.organizacion_id}
)
if created:
partidas_creadas += 1
# Evaluar estado de descarga sobre el conjunto completo
partidas = list(pedimento.partidas.order_by('numero_partida'))
total = len(partidas)
descargadas = [p.numero_partida for p in partidas if p.descargado]
no_descargadas = [p.numero_partida for p in partidas if not p.descargado]
if not no_descargadas:
estado = 'completado'
mensaje = f'Todas las partidas están descargadas ({total}/{total})'
else:
estado = 'en_proceso'
mensaje = f'{len(no_descargadas)} de {total} partidas pendientes de descarga'
return Response({
'message': message,
'task_id': task.id
'pedimento_id': str(pedimento_id),
'pedimento': pedimento.pedimento,
'estado': estado,
'mensaje': mensaje,
'resumen': {
'total_partidas': total,
'partidas_creadas_ahora': partidas_creadas,
'descargadas': len(descargadas),
'no_descargadas': len(no_descargadas),
},
'no_descargadas': no_descargadas,
}, status=status.HTTP_200_OK)
@swagger_auto_schema(
@@ -180,7 +236,7 @@ def auditar_pedimentos_endpoint(request):
@swagger_auto_schema(
method='post',
operation_description="Audita el procesamiento de remesa para un pedimento específico",
operation_description="Audita el estado de procesamiento de remesa de un pedimento específico",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
@@ -189,7 +245,7 @@ def auditar_pedimentos_endpoint(request):
required=['pedimento_id']
),
responses={
200: openapi.Response('Tarea de auditoría iniciada correctamente'),
200: openapi.Response('Estado de procesamiento de remesa del pedimento'),
400: openapi.Response('Error en los parámetros'),
403: openapi.Response('No tiene permisos suficientes'),
404: openapi.Response('Pedimento no encontrado')
@@ -198,247 +254,179 @@ def auditar_pedimentos_endpoint(request):
@api_view(['POST'])
@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)])
def auditar_procesamiento_remesa_pedimento_endpoint(request):
"""
Inicia una tarea de auditoría de remesa para un pedimento específico.
Verifica el estado del procesamiento de remesa y la creación de COVEs.
"""
pedimento_id = request.data.get('pedimento_id')
if not pedimento_id:
return Response(
{'error': 'Debe proporcionar pedimento_id'},
status=status.HTTP_400_BAD_REQUEST
)
return Response({'error': 'Debe proporcionar pedimento_id'}, status=status.HTTP_400_BAD_REQUEST)
# Validar permisos y existencia del pedimento
try:
pedimento = Pedimento.objects.get(id=pedimento_id)
user = request.user
if not user.is_superuser and str(pedimento.organizacion.id) != str(user.organizacion.id):
return Response(
{'error': 'No tiene permisos para este pedimento'},
status=status.HTTP_403_FORBIDDEN
)
pedimento = Pedimento.objects.select_related('organizacion').prefetch_related('coves').get(id=pedimento_id)
except Pedimento.DoesNotExist:
return Response(
{'error': 'Pedimento no encontrado'},
status=status.HTTP_404_NOT_FOUND
)
return Response({'error': 'Pedimento no encontrado'}, status=status.HTTP_404_NOT_FOUND)
# Ejecutar la tarea de auditoría
task = auditar_procesamiento_remesa_por_pedimento.delay(pedimento_id)
message = f"Auditoría de remesa iniciada para el pedimento {pedimento_id}"
user = request.user
if not user.is_superuser and str(pedimento.organizacion.id) != str(user.organizacion.id):
return Response({'error': 'No tiene permisos para este pedimento'}, status=status.HTTP_403_FORBIDDEN)
if not pedimento.remesas:
return Response({
'pedimento_id': str(pedimento_id),
'pedimento': pedimento.pedimento,
'tiene_remesas': False,
'estado': 'completado',
'mensaje': 'El pedimento no tiene remesas para procesar',
}, status=status.HTTP_200_OK)
tiene_documento_remesa = pedimento.documents.filter(document_type=3).exists()
coves = list(pedimento.coves.all())
total_coves = len(coves)
if not tiene_documento_remesa:
estado = 'en_proceso'
mensaje = 'Documento XML de remesa aún no descargado'
elif total_coves == 0:
estado = 'en_proceso'
mensaje = 'Documento de remesa disponible pero no se han creado COVEs'
else:
estado = 'completado'
mensaje = f'Remesa procesada — {total_coves} COVE(s) registrados'
return Response({
'message': message,
'task_id': task.id,
'pedimento': {
'id': str(pedimento.id),
'pedimento': pedimento.pedimento,
'tiene_remesas': pedimento.remesas
}
'pedimento_id': str(pedimento_id),
'pedimento': pedimento.pedimento,
'tiene_remesas': True,
'estado': estado,
'mensaje': mensaje,
'resumen': {
'tiene_documento_remesa': tiene_documento_remesa,
'total_coves_registrados': total_coves,
},
'coves': [c.numero_cove for c in coves],
}, status=status.HTTP_200_OK)
def _lanzar_auditoria_organizacion(request, task_fn, label):
"""Helper compartido para los 4 endpoints de auditoría masiva por organización."""
organizacion_id = request.data.get('organizacion_id')
if not organizacion_id:
return Response({'error': 'Debe proporcionar organizacion_id'}, status=status.HTTP_400_BAD_REQUEST)
user = request.user
if not user.is_superuser and str(user.organizacion.id) != organizacion_id:
return Response({'error': 'No tiene permisos para esta organización'}, status=status.HTTP_403_FORBIDDEN)
task = task_fn.delay(organizacion_id)
return Response({
'organizacion_id': organizacion_id,
'auditoria': label,
'task_id': task.id,
'mensaje': f'Auditoría de {label} iniciada. Consulta el resultado en GET /api/tasks/status/{task.id}/',
}, status=status.HTTP_202_ACCEPTED)
@swagger_auto_schema(
method='post',
operation_description="Audita los COVEs de una organización",
operation_description="Audita el estado de descarga de COVEs de todos los pedimentos de una organización",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'organizacion_id': openapi.Schema(type=openapi.TYPE_STRING, description='ID de la organización')
},
properties={'organizacion_id': openapi.Schema(type=openapi.TYPE_STRING)},
required=['organizacion_id']
),
responses={
200: openapi.Response('Tarea de auditoría iniciada correctamente'),
202: openapi.Response('Tarea iniciada — usar task_id para consultar resultado'),
400: openapi.Response('Error en los parámetros'),
403: openapi.Response('No tiene permisos suficientes')
403: openapi.Response('No tiene permisos suficientes'),
}
)
@api_view(['POST'])
@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)])
def auditar_coves_endpoint(request):
"""
Inicia una tarea de auditoría para los COVEs de una organización.
Verifica la existencia y validez de los COVEs generados.
"""
organizacion_id = request.data.get('organizacion_id')
if not organizacion_id:
return Response(
{'error': 'Debe proporcionar organizacion_id'},
status=status.HTTP_400_BAD_REQUEST
)
# Validar permisos
user = request.user
if not user.is_superuser and str(user.organizacion.id) != organizacion_id:
return Response(
{'error': 'No tiene permisos para esta organización'},
status=status.HTTP_403_FORBIDDEN
)
# Ejecutar la tarea de auditoría
task = auditar_coves.delay(organizacion_id)
message = f"Auditoría de COVEs iniciada para la organización {organizacion_id}"
return Response({
'message': message,
'task_id': task.id
}, status=status.HTTP_200_OK)
return _lanzar_auditoria_organizacion(request, auditar_coves, 'COVEs')
@swagger_auto_schema(
method='post',
operation_description="Audita los acuses de COVE de una organización",
operation_description="Audita el estado de descarga de acuses de COVE de todos los pedimentos de una organización",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'organizacion_id': openapi.Schema(type=openapi.TYPE_STRING, description='ID de la organización')
},
properties={'organizacion_id': openapi.Schema(type=openapi.TYPE_STRING)},
required=['organizacion_id']
),
responses={
200: openapi.Response('Tarea de auditoría iniciada correctamente'),
202: openapi.Response('Tarea iniciada — usar task_id para consultar resultado'),
400: openapi.Response('Error en los parámetros'),
403: openapi.Response('No tiene permisos suficientes')
403: openapi.Response('No tiene permisos suficientes'),
}
)
@api_view(['POST'])
@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)])
def auditar_acuse_cove_endpoint(request):
"""
Inicia una tarea de auditoría para los acuses de COVE de una organización.
Verifica la recepción y validez de los acuses de COVE.
"""
organizacion_id = request.data.get('organizacion_id')
if not organizacion_id:
return Response(
{'error': 'Debe proporcionar organizacion_id'},
status=status.HTTP_400_BAD_REQUEST
)
# Validar permisos
user = request.user
if not user.is_superuser and str(user.organizacion.id) != organizacion_id:
return Response(
{'error': 'No tiene permisos para esta organización'},
status=status.HTTP_403_FORBIDDEN
)
# Ejecutar la tarea de auditoría
task = auditar_acuse_cove.delay(organizacion_id)
message = f"Auditoría de acuses de COVE iniciada para la organización {organizacion_id}"
return Response({
'message': message,
'task_id': task.id
}, status=status.HTTP_200_OK)
return _lanzar_auditoria_organizacion(request, auditar_acuse_cove, 'acuses de COVE')
@swagger_auto_schema(
method='post',
operation_description="Audita los EDocuments de una organización",
operation_description="Audita el estado de descarga de EDocuments de todos los pedimentos de una organización",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'organizacion_id': openapi.Schema(type=openapi.TYPE_STRING, description='ID de la organización')
},
properties={'organizacion_id': openapi.Schema(type=openapi.TYPE_STRING)},
required=['organizacion_id']
),
responses={
200: openapi.Response('Tarea de auditoría iniciada correctamente'),
202: openapi.Response('Tarea iniciada — usar task_id para consultar resultado'),
400: openapi.Response('Error en los parámetros'),
403: openapi.Response('No tiene permisos suficientes')
403: openapi.Response('No tiene permisos suficientes'),
}
)
@api_view(['POST'])
@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)])
def auditar_edocuments_endpoint(request):
"""
Inicia una tarea de auditoría para los EDocuments de una organización.
Verifica la existencia y validez de los EDocuments generados.
"""
organizacion_id = request.data.get('organizacion_id')
if not organizacion_id:
return Response(
{'error': 'Debe proporcionar organizacion_id'},
status=status.HTTP_400_BAD_REQUEST
)
# Validar permisos
user = request.user
if not user.is_superuser and str(user.organizacion.id) != organizacion_id:
return Response(
{'error': 'No tiene permisos para esta organización'},
status=status.HTTP_403_FORBIDDEN
)
# Ejecutar la tarea de auditoría
task = auditar_edocuments.delay(organizacion_id)
message = f"Auditoría de EDocuments iniciada para la organización {organizacion_id}"
return Response({
'message': message,
'task_id': task.id
}, status=status.HTTP_200_OK)
return _lanzar_auditoria_organizacion(request, auditar_edocuments, 'EDocuments')
@swagger_auto_schema(
method='post',
operation_description="Audita los acuses de una organización",
operation_description="Audita el estado de descarga de acuses de EDocument de todos los pedimentos de una organización",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'organizacion_id': openapi.Schema(type=openapi.TYPE_STRING, description='ID de la organización')
},
properties={'organizacion_id': openapi.Schema(type=openapi.TYPE_STRING)},
required=['organizacion_id']
),
responses={
200: openapi.Response('Tarea de auditoría iniciada correctamente'),
202: openapi.Response('Tarea iniciada — usar task_id para consultar resultado'),
400: openapi.Response('Error en los parámetros'),
403: openapi.Response('No tiene permisos suficientes')
403: openapi.Response('No tiene permisos suficientes'),
}
)
@api_view(['POST'])
@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)])
def auditar_acuse_endpoint(request):
"""
Inicia una tarea de auditoría para los acuses de una organización.
Verifica la recepción y validez de los acuses.
"""
organizacion_id = request.data.get('organizacion_id')
if not organizacion_id:
return Response(
{'error': 'Debe proporcionar organizacion_id'},
status=status.HTTP_400_BAD_REQUEST
)
# Validar permisos
user = request.user
if not user.is_superuser and str(user.organizacion.id) != organizacion_id:
return Response(
{'error': 'No tiene permisos para esta organización'},
status=status.HTTP_403_FORBIDDEN
)
# Ejecutar la tarea de auditoría
task = auditar_acuse.delay(organizacion_id)
message = f"Auditoría de acuses iniciada para la organización {organizacion_id}"
return Response({
'message': message,
'task_id': task.id
}, status=status.HTTP_200_OK)
return _lanzar_auditoria_organizacion(request, auditar_acuse, 'acuses de EDocument')
@swagger_auto_schema(
method='post',
operation_description="Audita el COVE de un pedimento específico",
operation_description="Audita el estado de descarga de remesas de todos los pedimentos de una organización",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={'organizacion_id': openapi.Schema(type=openapi.TYPE_STRING)},
required=['organizacion_id']
),
responses={
202: openapi.Response('Tarea iniciada — usar task_id para consultar resultado'),
400: openapi.Response('Error en los parámetros'),
403: openapi.Response('No tiene permisos suficientes'),
}
)
@api_view(['POST'])
@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)])
def auditar_remesas_endpoint(request):
return _lanzar_auditoria_organizacion(request, auditar_remesas, 'remesas')
@swagger_auto_schema(
method='post',
operation_description="Audita el estado de descarga de COVEs de un pedimento específico",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
@@ -447,7 +435,7 @@ def auditar_acuse_endpoint(request):
required=['pedimento_id']
),
responses={
200: openapi.Response('Tarea de auditoría iniciada correctamente'),
200: openapi.Response('Estado de descarga de COVEs del pedimento'),
400: openapi.Response('Error en los parámetros'),
403: openapi.Response('No tiene permisos suficientes'),
404: openapi.Response('Pedimento no encontrado')
@@ -459,19 +447,48 @@ def auditar_cove_pedimento_endpoint(request):
pedimento_id = request.data.get('pedimento_id')
if not pedimento_id:
return Response({'error': 'Debe proporcionar pedimento_id'}, status=status.HTTP_400_BAD_REQUEST)
try:
pedimento = Pedimento.objects.get(id=pedimento_id)
user = request.user
if not user.is_superuser and str(pedimento.organizacion.id) != str(user.organizacion.id):
return Response({'error': 'No tiene permisos para este pedimento'}, status=status.HTTP_403_FORBIDDEN)
pedimento = Pedimento.objects.select_related('organizacion').prefetch_related('coves').get(id=pedimento_id)
except Pedimento.DoesNotExist:
return Response({'error': 'Pedimento no encontrado'}, status=status.HTTP_404_NOT_FOUND)
task = auditar_cove_por_pedimento.delay(pedimento_id)
return Response({'message': f'Auditoría de COVE iniciada para el pedimento {pedimento_id}', 'task_id': task.id}, status=status.HTTP_200_OK)
user = request.user
if not user.is_superuser and str(pedimento.organizacion.id) != str(user.organizacion.id):
return Response({'error': 'No tiene permisos para este pedimento'}, status=status.HTTP_403_FORBIDDEN)
coves = list(pedimento.coves.all())
total = len(coves)
descargados = sum(1 for c in coves if c.cove_descargado)
pendientes = [c.numero_cove for c in coves if not c.cove_descargado]
if total == 0:
nuevo_estado = 3
mensaje = 'El pedimento no tiene COVEs registrados'
elif descargados == total:
nuevo_estado = 3
mensaje = 'Todos los COVEs están descargados'
else:
nuevo_estado = 4
mensaje = f'{total - descargados} de {total} COVEs pendientes de descarga'
from api.customs.tasks.auditoria import modificar_estado_procesamiento
modificar_estado_procesamiento(pedimento, servicio_id=8, nuevo_estado=nuevo_estado)
return Response({
'pedimento_id': str(pedimento_id),
'estado': 'completado' if nuevo_estado == 3 else 'en_proceso',
'mensaje': mensaje,
'resumen': {
'total_coves': total,
'coves_descargados': descargados,
},
'pendientes': pendientes,
}, status=status.HTTP_200_OK)
@swagger_auto_schema(
method='post',
operation_description="Audita el acuse de COVE de un pedimento específico",
operation_description="Audita el estado de descarga de acuses de COVE de un pedimento específico",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
@@ -480,7 +497,7 @@ def auditar_cove_pedimento_endpoint(request):
required=['pedimento_id']
),
responses={
200: openapi.Response('Tarea de auditoría iniciada correctamente'),
200: openapi.Response('Estado de descarga de acuses de COVE del pedimento'),
400: openapi.Response('Error en los parámetros'),
403: openapi.Response('No tiene permisos suficientes'),
404: openapi.Response('Pedimento no encontrado')
@@ -492,19 +509,48 @@ def auditar_acuse_cove_pedimento_endpoint(request):
pedimento_id = request.data.get('pedimento_id')
if not pedimento_id:
return Response({'error': 'Debe proporcionar pedimento_id'}, status=status.HTTP_400_BAD_REQUEST)
try:
pedimento = Pedimento.objects.get(id=pedimento_id)
user = request.user
if not user.is_superuser and str(pedimento.organizacion.id) != str(user.organizacion.id):
return Response({'error': 'No tiene permisos para este pedimento'}, status=status.HTTP_403_FORBIDDEN)
pedimento = Pedimento.objects.select_related('organizacion').prefetch_related('coves').get(id=pedimento_id)
except Pedimento.DoesNotExist:
return Response({'error': 'Pedimento no encontrado'}, status=status.HTTP_404_NOT_FOUND)
task = auditar_acuse_cove_por_pedimento.delay(pedimento_id)
return Response({'message': f'Auditoría de acuse de COVE iniciada para el pedimento {pedimento_id}', 'task_id': task.id}, status=status.HTTP_200_OK)
user = request.user
if not user.is_superuser and str(pedimento.organizacion.id) != str(user.organizacion.id):
return Response({'error': 'No tiene permisos para este pedimento'}, status=status.HTTP_403_FORBIDDEN)
coves = list(pedimento.coves.all())
total = len(coves)
descargados = sum(1 for c in coves if c.acuse_cove_descargado)
pendientes = [c.numero_cove for c in coves if not c.acuse_cove_descargado]
if total == 0:
nuevo_estado = 3
mensaje = 'El pedimento no tiene COVEs registrados, no hay acuses que auditar'
elif descargados == total:
nuevo_estado = 3
mensaje = 'Todos los acuses de COVE están descargados'
else:
nuevo_estado = 4
mensaje = f'{total - descargados} de {total} acuses de COVE pendientes de descarga'
from api.customs.tasks.auditoria import modificar_estado_procesamiento
modificar_estado_procesamiento(pedimento, servicio_id=9, nuevo_estado=nuevo_estado)
return Response({
'pedimento_id': str(pedimento_id),
'estado': 'completado' if nuevo_estado == 3 else 'en_proceso',
'mensaje': mensaje,
'resumen': {
'total_coves': total,
'acuses_descargados': descargados,
},
'pendientes': pendientes,
}, status=status.HTTP_200_OK)
@swagger_auto_schema(
method='post',
operation_description="Audita el EDocument de un pedimento específico",
operation_description="Audita el estado de descarga de EDocuments de un pedimento específico",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
@@ -513,7 +559,7 @@ def auditar_acuse_cove_pedimento_endpoint(request):
required=['pedimento_id']
),
responses={
200: openapi.Response('Tarea de auditoría iniciada correctamente'),
200: openapi.Response('Estado de descarga de EDocuments del pedimento'),
400: openapi.Response('Error en los parámetros'),
403: openapi.Response('No tiene permisos suficientes'),
404: openapi.Response('Pedimento no encontrado')
@@ -525,19 +571,48 @@ def auditar_edocument_pedimento_endpoint(request):
pedimento_id = request.data.get('pedimento_id')
if not pedimento_id:
return Response({'error': 'Debe proporcionar pedimento_id'}, status=status.HTTP_400_BAD_REQUEST)
try:
pedimento = Pedimento.objects.get(id=pedimento_id)
user = request.user
if not user.is_superuser and str(pedimento.organizacion.id) != str(user.organizacion.id):
return Response({'error': 'No tiene permisos para este pedimento'}, status=status.HTTP_403_FORBIDDEN)
pedimento = Pedimento.objects.select_related('organizacion').prefetch_related('documentos').get(id=pedimento_id)
except Pedimento.DoesNotExist:
return Response({'error': 'Pedimento no encontrado'}, status=status.HTTP_404_NOT_FOUND)
task = auditar_edocument_por_pedimento.delay(pedimento_id)
return Response({'message': f'Auditoría de EDocument iniciada para el pedimento {pedimento_id}', 'task_id': task.id}, status=status.HTTP_200_OK)
user = request.user
if not user.is_superuser and str(pedimento.organizacion.id) != str(user.organizacion.id):
return Response({'error': 'No tiene permisos para este pedimento'}, status=status.HTTP_403_FORBIDDEN)
edocuments = list(pedimento.documentos.all())
total = len(edocuments)
descargados = sum(1 for d in edocuments if d.edocument_descargado)
pendientes = [d.numero_edocument for d in edocuments if not d.edocument_descargado]
if total == 0:
nuevo_estado = 3
mensaje = 'El pedimento no tiene EDocuments registrados'
elif descargados == total:
nuevo_estado = 3
mensaje = 'Todos los EDocuments están descargados'
else:
nuevo_estado = 4
mensaje = f'{total - descargados} de {total} EDocuments pendientes de descarga'
from api.customs.tasks.auditoria import modificar_estado_procesamiento
modificar_estado_procesamiento(pedimento, servicio_id=7, nuevo_estado=nuevo_estado)
return Response({
'pedimento_id': str(pedimento_id),
'estado': 'completado' if nuevo_estado == 3 else 'en_proceso',
'mensaje': mensaje,
'resumen': {
'total_edocuments': total,
'edocuments_descargados': descargados,
},
'pendientes': pendientes,
}, status=status.HTTP_200_OK)
@swagger_auto_schema(
method='post',
operation_description="Audita el acuse de un pedimento específico",
operation_description="Audita el estado de descarga de acuses de EDocument de un pedimento específico",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
@@ -546,28 +621,56 @@ def auditar_edocument_pedimento_endpoint(request):
required=['pedimento_id']
),
responses={
200: openapi.Response('Tarea de auditoría iniciada correctamente'),
200: openapi.Response('Estado de descarga de acuses de EDocument del pedimento'),
400: openapi.Response('Error en los parámetros'),
403: openapi.Response('No tiene permisos suficientes'),
404: openapi.Response('Pedimento no encontrado')
}
)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def auditar_acuse_pedimento_endpoint(request):
pedimento_id = request.data.get('pedimento_id')
if not pedimento_id:
return Response({'error': 'Debe proporcionar pedimento_id'}, status=status.HTTP_400_BAD_REQUEST)
try:
pedimento = Pedimento.objects.get(id=pedimento_id)
user = request.user
if not user.is_superuser and str(pedimento.organizacion.id) != str(user.organizacion.id):
return Response({'error': 'No tiene permisos para este pedimento'}, status=status.HTTP_403_FORBIDDEN)
pedimento = Pedimento.objects.select_related('organizacion').prefetch_related('documentos').get(id=pedimento_id)
except Pedimento.DoesNotExist:
return Response({'error': 'Pedimento no encontrado'}, status=status.HTTP_404_NOT_FOUND)
task = auditar_acuse_por_pedimento.delay(pedimento_id)
return Response({'message': f'Auditoría de acuse iniciada para el pedimento {pedimento_id}', 'task_id': task.id}, status=status.HTTP_200_OK)
user = request.user
if not user.is_superuser and str(pedimento.organizacion.id) != str(user.organizacion.id):
return Response({'error': 'No tiene permisos para este pedimento'}, status=status.HTTP_403_FORBIDDEN)
edocuments = list(pedimento.documentos.all())
total = len(edocuments)
descargados = sum(1 for d in edocuments if d.acuse_descargado)
pendientes = [d.numero_edocument for d in edocuments if not d.acuse_descargado]
if total == 0:
nuevo_estado = 3
mensaje = 'El pedimento no tiene EDocuments registrados, no hay acuses que auditar'
elif descargados == total:
nuevo_estado = 3
mensaje = 'Todos los acuses de EDocument están descargados'
else:
nuevo_estado = 4
mensaje = f'{total - descargados} de {total} acuses de EDocument pendientes de descarga'
from api.customs.tasks.auditoria import modificar_estado_procesamiento
modificar_estado_procesamiento(pedimento, servicio_id=6, nuevo_estado=nuevo_estado)
return Response({
'pedimento_id': str(pedimento_id),
'estado': 'completado' if nuevo_estado == 3 else 'en_proceso',
'mensaje': mensaje,
'resumen': {
'total_edocuments': total,
'acuses_descargados': descargados,
},
'pendientes': pendientes,
}, status=status.HTTP_200_OK)
### Procesamiento de pedimentos ###
@swagger_auto_schema(
@@ -729,10 +832,10 @@ def auditar_peticion_respuesta_pedimento_completo(request):
for documento in documentos_peticion:
nombre_archivo = os.path.basename(documento.archivo.name)
ruta_temporal = get_document_path(documento)
documentos_lista_peticiones.append({
'id': str(documento.id),
'archivo': documento.archivo.path,
'archivo': ruta_temporal,
'archivo_original': nombre_archivo,
'extension': documento.extension,
'size': documento.size,
@@ -1620,45 +1723,42 @@ def auditar_pedimento_endpoint(request):
informacion_extraida = []
for documento in documentos_xml:
print(f"documento >>>> {documento}")
logger.info(f"documento >>>> {documento}")
try:
xml_info = {
'documento_id': str(documento.id),
'nombre_archivo': os.path.basename(documento.archivo.name),
'nombre_archivo': os.path.basename(str(documento.archivo)),
'tamanio': documento.size,
'extension': documento.extension,
'tipo_documento': documento.document_type.descripcion if documento.document_type else 'Desconocido'
}
# Intentar extraer información del XML
try:
with open(documento.archivo.path, 'r', encoding='utf-8') as xml_file:
xml_content = xml_file.read()
# Extraer información específica del XML
xml_content = get_document_content(documento)
if xml_content is None:
xml_info['error_lectura'] = 'No se pudo descargar el archivo'
else:
info_pedimento = extraer_info_pedimento_xml(xml_content)
if info_pedimento:
xml_info['informacion_extraida'] = info_pedimento
informacion_extraida.append(info_pedimento)
# Actualizar el pedimento con la información encontrada si es necesario
actualizar_info_pedimento(pedimento, info_pedimento)
except Exception as e:
xml_info['error_lectura'] = str(e)
xmls_analizados.append(xml_info)
except Exception as e:
xmls_analizados.append({
'documento_id': str(documento.id),
'nombre_archivo': os.path.basename(documento.archivo.name),
'nombre_archivo': os.path.basename(str(documento.archivo)),
'error': f'Error procesando archivo: {str(e)}'
})
# Ejecutar la tarea de auditoría completa
task = auditar_pedimento_por_id.delay(pedimento_id)
response_data = {
'pedimento_id': str(pedimento_id),
'pedimento': pedimento.pedimento,
@@ -1667,7 +1767,6 @@ def auditar_pedimento_endpoint(request):
'xmls_analizados': xmls_analizados,
'informacion_extraida': informacion_extraida,
'auditoria_completa': True,
'task_id': task.id,
'mensaje': f'Auditoría completada para el pedimento {pedimento.pedimento}'
}

View File

@@ -3,7 +3,8 @@ from django.db import models
# Create your models here.
class DataStage(models.Model):
organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='datastages', null=True, blank=True)
archivo = models.FileField(upload_to='datastages/', blank=False, null=False)
# archivo = models.FileField(upload_to='datastages/', blank=False, null=False)
archivo = models.CharField(max_length=500, blank=True, null=True)
contribuyente = models.CharField(max_length=100, blank=False, null=False)
procesado = models.BooleanField(default=False)
@@ -83,7 +84,9 @@ class Registro501(models.Model):
organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='registro501s', null=True, blank=True)
consulta = models.CharField(max_length=50, null=True, blank=True)
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro501s', null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro501'
@@ -103,6 +106,8 @@ class Registro502(models.Model):
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro502s', null=True, blank=True)
patente = models.CharField(max_length=50, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro502'
@@ -119,6 +124,8 @@ class Registro503(models.Model):
consulta = models.CharField(max_length=50, null=True, blank=True)
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro503s', null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro503'
@@ -135,6 +142,8 @@ class Registro504(models.Model):
consulta = models.CharField(max_length=50, null=True, blank=True)
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro504s', null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro504'
@@ -164,6 +173,8 @@ class Registro505(models.Model):
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro505s', null=True, blank=True)
patente = models.CharField(max_length=50, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro505'
@@ -180,6 +191,8 @@ class Registro506(models.Model):
consulta = models.CharField(max_length=50, null=True, blank=True)
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro506s', null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro506'
@@ -198,6 +211,8 @@ class Registro507(models.Model):
consulta = models.CharField(max_length=50, null=True, blank=True)
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro507s', null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro507'
@@ -222,6 +237,8 @@ class Registro508(models.Model):
consulta = models.CharField(max_length=50, null=True, blank=True)
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro508s', null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro508'
@@ -240,6 +257,8 @@ class Registro509(models.Model):
consulta = models.CharField(max_length=50, null=True, blank=True)
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro509s', null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro509'
@@ -260,6 +279,8 @@ class Registro510(models.Model):
forma_pago = models.CharField(max_length=3, null=True, blank=True)
importe_pago = models.CharField(max_length=12, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro510'
@@ -277,6 +298,8 @@ class Registro511(models.Model):
consulta = models.CharField(max_length=50, null=True, blank=True)
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro511s', null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro511'
@@ -300,6 +323,8 @@ class Registro512(models.Model):
consulta = models.CharField(max_length=50, null=True, blank=True)
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro512s', null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro512'
@@ -362,6 +387,8 @@ class Registro551(models.Model):
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro551s', null=True, blank=True)
entidad_fed_destino = models.CharField(max_length=50, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro551'
@@ -380,6 +407,8 @@ class Registro552(models.Model):
consulta = models.CharField(max_length=50, null=True, blank=True)
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro552s', null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro552'
@@ -401,6 +430,8 @@ class Registro553(models.Model):
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro553s', null=True, blank=True)
consulta = models.CharField(max_length=50, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro553'
@@ -420,6 +451,8 @@ class Registro554(models.Model):
consulta = models.CharField(max_length=50, null=True, blank=True)
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro554s', null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro554'
@@ -445,6 +478,8 @@ class Registro555(models.Model):
created_by = models.IntegerField(null=True, blank=True)
consulta = models.CharField(max_length=50, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro555'
@@ -464,6 +499,8 @@ class Registro556(models.Model):
fraccion = models.CharField(max_length=8, null=True, blank=True)
secuencia_fraccion = models.CharField(max_length=50, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro556'
@@ -483,6 +520,8 @@ class Registro557(models.Model):
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro557s', null=True, blank=True)
consulta = models.CharField(max_length=50, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro557'
@@ -501,6 +540,8 @@ class Registro558(models.Model):
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro558s', null=True, blank=True)
consulta = models.CharField(max_length=50, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro558'
@@ -521,6 +562,8 @@ class RegistroSel(models.Model):
consulta = models.CharField(max_length=50, null=True, blank=True)
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro_sel', null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro_sel'
@@ -545,6 +588,8 @@ class Registro701(models.Model):
consulta = models.CharField(max_length=50, null=True, blank=True)
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro701s', null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro701'
@@ -563,6 +608,8 @@ class Registro702(models.Model):
consulta = models.CharField(max_length=50, null=True, blank=True)
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro702s', null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro702'

View File

@@ -1,12 +1,86 @@
from api.utils.storage_service import storage_service
from rest_framework import serializers
from .models import DataStage
from api.organization.models import Organizacion
class DataStageSerializer(serializers.ModelSerializer):
archivo = serializers.FileField(write_only=True, required=False, allow_null=True)
download_url = serializers.SerializerMethodField(read_only=True)
organizacion = serializers.PrimaryKeyRelatedField(required=False, allow_null=True, queryset=Organizacion.objects.all())
class Meta:
model = DataStage
fields = '__all__'
read_only_fields = ('id', 'created_at', 'updated_at')
read_only_fields = ('id', 'created_at', 'updated_at')
# extra_kwargs = {'archivo': {'read_only': True},}
def get_download_url(self, obj):
"""Retorna URL de descarga según dónde esté el archivo"""
if not obj.archivo:
return None
if storage_service.is_minio_path(obj.archivo):
return storage_service.get_file_url(obj.archivo)
else:
request = self.context.get('request')
if request:
return request.build_absolute_uri(
f"/api/v1/datastage/datastages/{obj.id}/download-datastage/"
)
return f"/api/v1/datastage/datastages/{obj.id}/download-datastage/"
def create(self, validated_data):
"""Override para manejar la subida del archivo a MinIO"""
archivo_file = validated_data.pop('archivo', None)
organizacion = validated_data.get('organizacion')
datastage = super().create(validated_data)
print(f"ENDPOINT DE CREATE >>>>")
# guardarlo en MinIO
if archivo_file:
ruta = storage_service.save_datastage(
file=archivo_file,
organizacion_id=organizacion.id if organizacion else datastage.organizacion.id,
metadata={
'datastage_id': str(datastage.id),
'nombre': datastage.nombre if hasattr(datastage, 'nombre') else ''
}
)
if ruta:
datastage.archivo = ruta
datastage.save()
else:
# eliminar el registro creado
datastage.delete()
raise serializers.ValidationError({"archivo": "Error al guardar el archivo en el almacenamiento"})
return datastage
def update(self, instance, validated_data):
"""Override para manejar actualización de archivo"""
archivo_file = validated_data.pop('archivo', None)
organizacion = validated_data.get('organizacion', instance.organizacion)
instance = super().update(instance, validated_data)
# Si hay nuevo archivo, reemplazarlo
if archivo_file:
if instance.archivo:
storage_service.delete_file(instance.archivo)
ruta = storage_service.save_datastage(
file=archivo_file,
organizacion_id=organizacion.id,
metadata={
'datastage_id': str(instance.id),
'updated': 'true'
}
)
if ruta:
instance.archivo = ruta
instance.save()
else:
raise serializers.ValidationError({"archivo": "Error al guardar el nuevo archivo"})
return instance

View File

@@ -1,3 +1,4 @@
import tempfile
from celery import group
from celery import shared_task
import logging
@@ -6,81 +7,132 @@ from django.utils import timezone
import os
import zipfile
import re
from api.utils.storage_service import storage_service
logger = logging.getLogger(__name__)
@shared_task
def procesar_datastage_task(datastage_id, user_organizacion_id=None):
import traceback
tmp_path = None
try:
logger = logging.getLogger(__name__)
from api.datastage.models import DataStage
from api.organization.models import Organizacion
from api.customs.models import Pedimento, TipoOperacion, Regimen
datastage = DataStage.objects.get(id=datastage_id)
# Obtener datastage
try:
datastage = DataStage.objects.get(id=datastage_id)
except DataStage.DoesNotExist:
return {'error': f'DataStage {datastage_id} no encontrado'}
# Validar archivo
if not datastage.archivo:
print("DataStage no tiene archivo asociado")
return {'detail': 'No hay archivo asociado a este DataStage.'}
file_path = datastage.archivo.path
if not os.path.exists(file_path):
return {'detail': 'El archivo no existe en el servidor.'}
if not file_path.endswith('.zip'):
ruta_archivo = str(datastage.archivo)
if not ruta_archivo.lower().endswith('.zip'):
return {'detail': 'El archivo no es un .zip.'}
documentos_encontrados = []
registros_cargados = {}
registros_por_archivo = {}
errores_por_archivo = {}
errores_pedimento = []
# Descargar archivo
with tempfile.NamedTemporaryFile(delete=False, suffix='.zip') as tmp:
tmp_path = tmp.name
success = storage_service.download_file(ruta_archivo, tmp_path)
if not success:
print(f"No se pudo descargar: {ruta_archivo}")
return {'detail': f'No se pudo descargar el archivo: {ruta_archivo}'}
file_path = tmp_path
# Obtener organización
user_organizacion = None
if user_organizacion_id:
user_organizacion = Organizacion.objects.get(id=user_organizacion_id)
try:
user_organizacion = Organizacion.objects.get(id=user_organizacion_id)
except Organizacion.DoesNotExist:
print(f"Organización no encontrada: {user_organizacion_id}")
def to_snake_case(name):
s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
s2 = re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1)
return s2.replace('__', '_').lower()
# Lanzar una subtarea por cada archivo ASC
# Leer ZIP y lanzar subtareas
subtasks = []
with zipfile.ZipFile(file_path, 'r') as zip_ref:
for asc_name in zip_ref.namelist():
namelist = zip_ref.namelist()
for asc_name in namelist:
if asc_name.endswith('.asc'):
subtasks.append(procesar_archivo_asc_task.s(datastage_id, user_organizacion_id, asc_name))
subtasks.append(
procesar_archivo_asc_task.s(datastage_id, user_organizacion_id, asc_name)
)
if subtasks:
job = group(subtasks).apply_async()
print(f"Grupo de tareas lanzado: {job.id}")
return {
'group_id': job.id,
'subtask_ids': [t.id for t in job.results],
'detail': 'Procesamiento lanzado. Monitorea el estado de cada subtask_id.'
'detail': f'Procesamiento lanzado. {len(subtasks)} archivos .ASC en cola.'
}
print("No se encontraron archivos .ASC")
return {'detail': 'No se encontraron archivos .asc'}
except Exception as e:
import traceback
return {'error': str(e), 'traceback': traceback.format_exc()}
finally:
# Limpiar temporal
if tmp_path and os.path.exists(tmp_path):
try:
os.unlink(tmp_path)
except Exception as e:
print(f"No se pudo eliminar temporal: {e}")
@shared_task
def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name):
import traceback
"""
Procesa un archivo .ASC individual dentro del ZIP
"""
tmp_path = None
try:
logger = logging.getLogger(__name__)
from api.datastage.models import DataStage
from api.organization.models import Organizacion
from api.customs.models import Pedimento, TipoOperacion, Regimen
from django.apps import apps
import zipfile
import re
import datetime
# Obtener datastage
datastage = DataStage.objects.get(id=datastage_id)
user_organizacion = None
if user_organizacion_id:
user_organizacion = Organizacion.objects.get(id=user_organizacion_id)
file_path = datastage.archivo.path
ruta_archivo = str(datastage.archivo)
# Descargar archivo
with tempfile.NamedTemporaryFile(delete=False, suffix='.zip') as tmp:
tmp_path = tmp.name
success = storage_service.download_file(ruta_archivo, tmp_path)
if not success:
return {'errores': [f'No se pudo descargar el archivo: {ruta_archivo}']}
file_path = tmp_path
def to_snake_case(name):
s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
s2 = re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1)
return s2.replace('__', '_').lower()
objects_to_create = []
with zipfile.ZipFile(file_path, 'r') as zip_ref:
if asc_name not in zip_ref.namelist():
print(f"{asc_name} no encontrado en el ZIP")
return {'errores': [f'{asc_name} no encontrado en el zip']}
# Determinar modelo
match = re.match(r'.*_(\d+)\.asc$', asc_name)
if match:
registro_key = match.group(1)
@@ -96,71 +148,86 @@ def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name):
Model = apps.get_model('datastage', model_name)
except LookupError:
return {'errores': [f"No existe el modelo para {model_name}"]}
# Procesar archivo
with zip_ref.open(asc_name) as asc_file:
first = True
field_names = []
field_names_snake = []
objects_to_create = []
errores_pedimento = []
line_count = 0
for line in asc_file:
line_decoded = None
line_count += 1
try:
line_decoded = line.decode('utf-8').strip()
except UnicodeDecodeError:
try:
line_decoded = line.decode('latin-1').strip()
except Exception as e:
except Exception:
continue
except Exception as e:
continue
if not line_decoded:
continue
if first:
field_names = [f for f in line_decoded.split('|')]
field_names = line_decoded.split('|')
# Eliminar columnas vacías del final (líneas terminan con |)
while field_names and field_names[-1] == '':
field_names.pop()
field_names_snake = [to_snake_case(f) for f in field_names]
first = False
continue
values = line_decoded.split('|')
while values and values[-1] == '':
values.pop()
if len(values) == len(field_names_snake) + 1 and values[-1] == '':
values = values[:-1]
if len(values) < len(field_names_snake):
values += [None] * (len(field_names_snake) - len(values))
if len(values) != len(field_names_snake):
logger.debug(
"%s línea %d: esperados %d campos, recibidos %d — se omite",
asc_name, line_count, len(field_names_snake), len(values)
)
continue
data = dict(zip(field_names_snake, values))
if hasattr(Model, 'organizacion_id'):
data['organizacion_id'] = user_organizacion.id if user_organizacion else None
if hasattr(Model, 'datastage_id'):
data['datastage_id'] = datastage.id
# Limpiar campos de fecha vacíos ('') a None
# Parsear y normalizar todos los campos de fecha/datetime
for field in Model._meta.get_fields():
if hasattr(field, 'get_internal_type') and field.get_internal_type() in ["DateField", "DateTimeField"]:
if data.get(field.name) == "":
data[field.name] = None
# Convertir fecha_pago_real a timezone-aware si existe
if 'fecha_pago_real' in data and data['fecha_pago_real']:
from django.utils import timezone
import datetime
fecha_val = data['fecha_pago_real']
if isinstance(fecha_val, str):
try:
dt = datetime.datetime.strptime(fecha_val, '%Y-%m-%d %H:%M:%S')
except ValueError:
if not hasattr(field, 'get_internal_type'):
continue
field_type = field.get_internal_type()
val = data.get(field.name)
if val == '' or val is None:
data[field.name] = None
continue
if field_type == 'DateTimeField' and isinstance(val, str):
dt = None
for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%d'):
try:
dt = datetime.datetime.strptime(fecha_val, '%Y-%m-%d')
except Exception:
dt = None
dt = datetime.datetime.strptime(val, fmt)
break
except ValueError:
continue
if dt and timezone.is_naive(dt):
dt = timezone.make_aware(dt)
if dt:
data['fecha_pago_real'] = dt
elif isinstance(fecha_val, datetime.datetime) and timezone.is_naive(fecha_val):
data['fecha_pago_real'] = timezone.make_aware(fecha_val)
data[field.name] = dt
# Filtrar data para solo incluir campos válidos del modelo
valid_fields = set()
for f in Model._meta.get_fields():
if hasattr(f, 'name'):
valid_fields.add(f.name)
if hasattr(f, 'attname'):
valid_fields.add(f.attname)
data = {k: v for k, v in data.items() if k in valid_fields}
try:
obj = Model(**data)
objects_to_create.append(obj)
# Si es Registro501, crear Pedimento
if model_name == 'Registro501':
organizacion_instance = None
@@ -169,7 +236,7 @@ def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name):
try:
organizacion_instance = Organizacion.objects.get(id=org_id)
except Exception as org_exc:
logger.warning(f"No se encontró la organización con id {org_id}: {org_exc}")
print(f"No se encontró la organización con id {org_id}: {org_exc}")
if not organizacion_instance:
organizacion_instance = user_organizacion
fecha_pago_raw = data.get('fecha_pago_real')
@@ -182,6 +249,7 @@ def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name):
else:
fecha_pago = fecha_pago_raw
aduana = data.get('seccion_aduanera')
# logger.info(f"aduana >>>> {aduana}")
patente = data.get('patente')
pedimento_num = data.get('pedimento')
pedimento_app = ""
@@ -191,9 +259,13 @@ def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name):
year = fecha_pago[:4]
else:
year = str(fecha_pago.year)
pedimento_app = f"{year[-2:]}-{str(aduana).zfill(2)[-2:]}-{str(patente).zfill(4)[-4:]}-{str(pedimento_num).zfill(7)[-7:]}"
# mantener aduana con sus digitos intactos
# pedimento_app = f"{year[-2:]}-{str(aduana).zfill(2)[-2:]}-{str(patente).zfill(4)[-4:]}-{str(pedimento_num).zfill(7)[-7:]}"
# pedimento_app = f"{year[-2:]}-{str(aduana)}-{str(patente).zfill(4)[-4:]}-{str(pedimento_num).zfill(7)[-7:]}"
pedimento_app = f"{year[-2:]}-{str(aduana).zfill(2)[:2]}-{str(patente).zfill(4)[-4:]}-{str(pedimento_num).zfill(7)[-7:]}"
# logger.info(f"pedimento_app >>>> {pedimento_app}")
except Exception as ped_app_exc:
logger.warning(f"No se pudo generar pedimento_app: {ped_app_exc}")
print(f"No se pudo generar pedimento_app: {ped_app_exc}")
tipo_operacion_val = data.get('tipo_operacion')
tipo_operacion = TipoOperacion.objects.filter(id=int(tipo_operacion_val)).first() if tipo_operacion_val else None
regimen = Regimen.objects.filter(claveped=data.get('clave_documento', '').strip(), tipo=tipo_operacion.id if tipo_operacion else None).first() if tipo_operacion else None
@@ -225,18 +297,23 @@ 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)
except Exception as ped_exc:
pass
logger.warning("No se pudo crear Pedimento %s: %s", pedimento_app, ped_exc)
except Exception as e:
logger.error("%s línea %d: error creando objeto %s: %s", asc_name, line_count, model_name, e)
continue
if objects_to_create:
try:
Model.objects.bulk_create(objects_to_create, batch_size=1000)
except Exception as e:
return {'archivo': asc_name, 'error': str(e), 'traceback': traceback.format_exc()}
# Bulk create
if objects_to_create:
try:
Model.objects.bulk_create(objects_to_create, batch_size=1000)
except Exception as e:
return {'archivo': asc_name, 'error': str(e)}
return {
'archivo': asc_name,
'insertados': len(objects_to_create)
@@ -244,33 +321,11 @@ def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name):
except Exception as e:
import traceback
return {'archivo': asc_name, 'error': str(e), 'traceback': traceback.format_exc()}
detalles = {}
for key in ['502', '503', '504']:
model_name = f'Registro{key}'
asc_file = None
encabezado = None
errores = []
for asc_name in registros_por_archivo:
if asc_name.endswith(f'_{key}.asc'):
asc_file = asc_name
break
if asc_file:
finally:
# Limpiar temporal
if tmp_path and os.path.exists(tmp_path):
try:
with zipfile.ZipFile(file_path, 'r') as zip_ref:
with zip_ref.open(asc_file) as f:
for line in f:
try:
encabezado = line.decode('utf-8').strip()
except UnicodeDecodeError:
encabezado = line.decode('latin-1').strip()
break
os.unlink(tmp_path)
except Exception as e:
encabezado = f'Error leyendo encabezado: {e}'
errores = errores_por_archivo.get(asc_file, [])
detalles[model_name] = {
'archivo': asc_file,
'encabezado': encabezado,
'errores': errores
}
return {'registros_cargados': registros_cargados, 'errores_pedimento': errores_pedimento}
print(f"No se pudo eliminar temporal: {e}")

View File

@@ -1,3 +1,8 @@
import atexit
import tempfile
from api.utils.storage_service import storage_service
from config import settings
from rest_framework.pagination import PageNumberPagination
from api.customs.models import Pedimento, TipoOperacion, Regimen
from django.shortcuts import render
@@ -112,18 +117,65 @@ class DataStageViewSet(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
def download_datastage(self, request, pk=None):
"""
Endpoint para descargar el archivo asociado a un DataStage.
Soporta tanto archivos en MinIO como archivos locales antiguos.
"""
try:
datastage = self.get_object()
if not datastage.archivo:
raise Http404("No hay archivo asociado a este DataStage.")
file_path = datastage.archivo.path
if not os.path.exists(file_path):
raise Http404("El archivo no existe en el servidor.")
response = FileResponse(open(file_path, 'rb'), as_attachment=True, filename=os.path.basename(file_path))
return response
# Detectar si es ruta de MinIO o local
is_minio_path = datastage.archivo.startswith('org_')
if is_minio_path:
import tempfile
with tempfile.NamedTemporaryFile(delete=False) as tmp:
tmp_path = tmp.name
success = storage_service.download_file(datastage.archivo, tmp_path)
if not success:
raise Http404("No se pudo descargar el archivo de MinIO")
filename = os.path.basename(datastage.archivo)
response = FileResponse(
open(tmp_path, 'rb'),
as_attachment=True,
filename=filename
)
import atexit
atexit.register(lambda: os.unlink(tmp_path) if os.path.exists(tmp_path) else None)
return response
else:
file_path = os.path.join(settings.MEDIA_ROOT, str(datastage.archivo))
if not os.path.exists(file_path):
raise Http404(f"El archivo no existe: {file_path}")
filename = os.path.basename(file_path)
response = FileResponse(
open(file_path, 'rb'),
as_attachment=True,
filename=filename
)
return response
except Exception as e:
return Response({'detail': str(e)}, status=404)
def perform_destroy(self, instance):
"""
Al eliminar un DataStage, también eliminar su archivo asociado.
"""
if instance.archivo:
storage_service.delete_file(instance.archivo)
instance.delete()
@action(detail=True, methods=['post'], url_path='procesar')
def procesar(self, request, pk=None):

View File

View File

View File

@@ -0,0 +1,472 @@
import os
import time
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor, as_completed
from django.core.management.base import BaseCommand
from django.conf import settings
from minio import Minio
from api.record.models import Document
from api.datastage.models import DataStage
from api.vucem.models import Vucem
from api.reports.models import ReportDocument
class Command(BaseCommand):
help = 'Migra archivos existentes del sistema local a MinIO (versión optimizada)'
def add_arguments(self, parser):
parser.add_argument('--dry-run', action='store_true', help='Solo muestra lo que se migraría')
parser.add_argument('--model', type=str, help='Document, DataStage, Vucem, ReportDocument')
parser.add_argument('--limit', type=int, help='Límite de registros')
parser.add_argument('--batch-size', type=int, default=200, help='Tamaño del lote (default: 200)')
parser.add_argument('--workers', type=int, default=3, help='Número de workers (default: 3)')
parser.add_argument('--offset', type=int, default=0, help='Offset inicial (para reanudar)')
def __init__(self):
super().__init__()
self.client = None
self.bucket_name = None
def _init_minio_client(self):
"""Inicializa el cliente MinIO"""
if self.client is None:
self.client = Minio(
endpoint=os.getenv('MINIO_ENDPOINT', 'minio:9000'),
access_key=os.getenv('MINIO_ACCESS_KEY'),
secret_key=os.getenv('MINIO_SECRET_KEY'),
secure=os.getenv('MINIO_SECURE', 'false').lower() == 'true'
)
self.bucket_name = os.getenv('MINIO_BUCKET_NAME', 'efc-backend-dev')
def handle(self, *args, **options):
dry_run = options.get('dry_run', False)
model_filter = options.get('model')
limit = options.get('limit')
batch_size = options.get('batch_size', 200)
workers = options.get('workers', 3)
offset = options.get('offset', 0)
self.stdout.write(self.style.WARNING('=' * 60))
self.stdout.write(self.style.WARNING('INICIANDO MIGRACIÓN A MINIO (OPTIMIZADA)'))
self.stdout.write(self.style.WARNING(f'Batch size: {batch_size} | Workers: {workers} | Offset: {offset}'))
if dry_run:
self.stdout.write(self.style.WARNING('MODO: DRY RUN (sin cambios)'))
self.stdout.write(self.style.WARNING('=' * 60))
results = {}
if not model_filter or model_filter.lower() == 'document':
results['Document'] = self.migrate_documents(dry_run, limit, batch_size, workers, offset)
if not model_filter or model_filter.lower() == 'datastage':
results['DataStage'] = self.migrate_datastage(dry_run, limit, batch_size, workers, offset)
if not model_filter or model_filter.lower() == 'vucem':
results['Vucem'] = self.migrate_vucem(dry_run, limit, workers)
if not model_filter or model_filter.lower() == 'reportdocument':
results['ReportDocument'] = self.migrate_reports(dry_run, limit, batch_size, workers, offset)
# Resumen final
self.stdout.write('\n' + '=' * 60)
self.stdout.write(self.style.SUCCESS('RESUMEN DE MIGRACIÓN'))
self.stdout.write('=' * 60)
total_migrados = 0
total_no_encontrados = 0
total_errores = 0
for model_name, stats in results.items():
self.stdout.write(f"\n📁 {model_name}:")
self.stdout.write(f" ✅ Migrados: {stats['migrated']}")
self.stdout.write(f" ⚠️ No encontrados: {stats['not_found']}")
self.stdout.write(f" ❌ Errores: {stats['errors']}")
total_migrados += stats['migrated']
total_no_encontrados += stats['not_found']
total_errores += stats['errors']
self.stdout.write('\n' + '-' * 40)
self.stdout.write(f"📊 TOTAL Migrados: {total_migrados}")
self.stdout.write(f"📊 TOTAL No encontrados: {total_no_encontrados}")
self.stdout.write(f"📊 TOTAL Errores: {total_errores}")
if dry_run:
self.stdout.write('\n' + self.style.WARNING('⚠️ MODO DRY RUN - No se realizaron cambios'))
def get_local_file_path(self, path_str):
"""Obtiene la ruta completa del archivo local"""
return Path(settings.MEDIA_ROOT) / path_str
def migrate_documents(self, dry_run, limit, batch_size, workers, offset):
"""Migra documentos del modelo Document"""
self._init_minio_client()
stats = {'migrated': 0, 'not_found': 0, 'errors': 0}
queryset = Document.objects.exclude(archivo='').exclude(archivo__isnull=True)
queryset = queryset.exclude(archivo__startswith='org_')
queryset = queryset.order_by('created_at')
if offset:
queryset = queryset[offset:]
if limit:
queryset = queryset[:limit]
total = queryset.count()
self.stdout.write(f"\n📄 Procesando {total} documentos...")
if total == 0:
return stats
start_time = time.time()
processed = 0
# Procesar en lotes
for batch_start in range(0, total, batch_size):
batch = queryset[batch_start:batch_start + batch_size]
batch_docs = list(batch)
if dry_run:
stats['migrated'] += len(batch_docs)
processed += len(batch_docs)
self._print_progress(processed, total, start_time, stats)
continue
# Preparar items para workers
items = []
for doc in batch_docs:
path_str = str(doc.archivo)
local_path = self.get_local_file_path(path_str)
if not local_path.exists():
stats['not_found'] += 1
continue
pedimento_app = doc.pedimento.pedimento_app if doc.pedimento else 'unknown'
items.append({
'doc': doc,
'local_path': local_path,
'path_str': path_str,
'pedimento_app': pedimento_app
})
# Procesar en paralelo
if items:
with ThreadPoolExecutor(max_workers=workers) as executor:
futures = {executor.submit(self._upload_document, item): item for item in items}
for future in as_completed(futures):
result = future.result()
if result['success']:
stats['migrated'] += 1
else:
stats['errors'] += 1
processed += len(batch_docs)
self._print_progress(processed, total, start_time, stats)
total_time = time.time() - start_time
self.stdout.write(f"\n ✅ Completado en {total_time/60:.1f} minutos")
return stats
def _upload_document(self, item):
"""Sube un documento directamente a MinIO"""
try:
doc = item['doc']
local_path = item['local_path']
pedimento_app = item['pedimento_app']
filename = local_path.name
# Generar ruta MinIO
object_name = f"org_{doc.organizacion_id}/documents/{pedimento_app}/{filename}"
# Subir directamente a MinIO
self.client.fput_object(
bucket_name=self.bucket_name,
object_name=object_name,
file_path=str(local_path)
)
# Actualizar base de datos
doc.archivo = object_name
doc.save(update_fields=['archivo'])
return {'success': True, 'doc_id': doc.id}
except Exception as e:
return {'success': False, 'doc_id': doc.id, 'error': str(e)}
def migrate_datastage(self, dry_run, limit, batch_size, workers, offset):
"""Migra archivos del modelo DataStage"""
self._init_minio_client()
stats = {'migrated': 0, 'not_found': 0, 'errors': 0}
queryset = DataStage.objects.exclude(archivo='').exclude(archivo__isnull=True)
queryset = queryset.exclude(archivo__startswith='org_')
queryset = queryset.order_by('created_at')
if offset:
queryset = queryset[offset:]
if limit:
queryset = queryset[:limit]
total = queryset.count()
self.stdout.write(f"\n📦 Procesando {total} archivos DataStage...")
if total == 0:
return stats
start_time = time.time()
processed = 0
for batch_start in range(0, total, batch_size):
batch = queryset[batch_start:batch_start + batch_size]
batch_docs = list(batch)
if dry_run:
stats['migrated'] += len(batch_docs)
processed += len(batch_docs)
self._print_progress(processed, total, start_time, stats)
continue
items = []
for ds in batch_docs:
path_str = str(ds.archivo)
local_path = self.get_local_file_path(path_str)
if not local_path.exists():
stats['not_found'] += 1
continue
items.append({'ds': ds, 'local_path': local_path})
if items:
with ThreadPoolExecutor(max_workers=workers) as executor:
futures = {executor.submit(self._upload_datastage, item): item for item in items}
for future in as_completed(futures):
result = future.result()
if result['success']:
stats['migrated'] += 1
else:
stats['errors'] += 1
processed += len(batch_docs)
self._print_progress(processed, total, start_time, stats)
total_time = time.time() - start_time
self.stdout.write(f"\n ✅ Completado en {total_time/60:.1f} minutos")
return stats
def _upload_datastage(self, item):
"""Sube un DataStage directamente a MinIO"""
try:
ds = item['ds']
local_path = item['local_path']
filename = local_path.name
object_name = f"org_{ds.organizacion_id}/datastage/{filename}"
self.client.fput_object(
bucket_name=self.bucket_name,
object_name=object_name,
file_path=str(local_path)
)
ds.archivo = object_name
ds.save(update_fields=['archivo'])
return {'success': True, 'id': ds.id}
except Exception as e:
return {'success': False, 'id': ds.id, 'error': str(e)}
def migrate_vucem(self, dry_run, limit, workers):
"""Migra archivos key y cer del modelo Vucem"""
self._init_minio_client()
stats = {'migrated': 0, 'not_found': 0, 'errors': 0}
queryset = Vucem.objects.all()
if limit:
queryset = queryset[:limit]
total = queryset.count() * 2
self.stdout.write(f"\n🔐 Procesando {queryset.count()} registros VUCEM (key + cer)...")
if total == 0:
return stats
items = []
for vucem in queryset:
if vucem.key and not str(vucem.key).startswith('org_'):
path_str = str(vucem.key)
local_path = self.get_local_file_path(path_str)
if local_path.exists():
items.append({'vucem': vucem, 'local_path': local_path, 'tipo': 'key'})
else:
stats['not_found'] += 1
if vucem.cer and not str(vucem.cer).startswith('org_'):
path_str = str(vucem.cer)
local_path = self.get_local_file_path(path_str)
if local_path.exists():
items.append({'vucem': vucem, 'local_path': local_path, 'tipo': 'cer'})
else:
stats['not_found'] += 1
if dry_run:
stats['migrated'] = len(items)
self.stdout.write(f" 📝 [DRY RUN] Se migrarían {len(items)} archivos")
return stats
if items:
with ThreadPoolExecutor(max_workers=workers) as executor:
futures = {executor.submit(self._upload_vucem, item): item for item in items}
for future in as_completed(futures):
result = future.result()
if result['success']:
stats['migrated'] += 1
self.stdout.write(self.style.SUCCESS(f"{result['tipo']} migrado: {result['id']}"))
else:
stats['errors'] += 1
return stats
def _upload_vucem(self, item):
"""Sube un archivo VUCEM directamente a MinIO"""
try:
vucem = item['vucem']
local_path = item['local_path']
tipo = item['tipo']
filename = local_path.name
if tipo == 'key':
object_name = f"org_{vucem.organizacion_id}/vucem_keys/{filename}"
vucem.key = object_name
vucem.save(update_fields=['key'])
else:
object_name = f"org_{vucem.organizacion_id}/vucem_certs/{filename}"
vucem.cer = object_name
vucem.save(update_fields=['cer'])
self.client.fput_object(
bucket_name=self.bucket_name,
object_name=object_name,
file_path=str(local_path)
)
return {'success': True, 'id': vucem.id, 'tipo': tipo}
except Exception as e:
return {'success': False, 'id': vucem.id, 'tipo': tipo, 'error': str(e)}
def migrate_reports(self, dry_run, limit, batch_size, workers, offset):
"""Migra archivos del modelo ReportDocument"""
self._init_minio_client()
stats = {'migrated': 0, 'not_found': 0, 'errors': 0}
queryset = ReportDocument.objects.exclude(file='').exclude(file__isnull=True)
queryset = queryset.exclude(file__startswith='org_')
queryset = queryset.order_by('created_at')
if offset:
queryset = queryset[offset:]
if limit:
queryset = queryset[:limit]
total = queryset.count()
self.stdout.write(f"\n📊 Procesando {total} reportes...")
if total == 0:
return stats
start_time = time.time()
processed = 0
for batch_start in range(0, total, batch_size):
batch = queryset[batch_start:batch_start + batch_size]
batch_docs = list(batch)
if dry_run:
stats['migrated'] += len(batch_docs)
processed += len(batch_docs)
self._print_progress(processed, total, start_time, stats)
continue
items = []
for report in batch_docs:
path_str = str(report.file)
local_path = self.get_local_file_path(path_str)
if not local_path.exists():
stats['not_found'] += 1
continue
items.append({'report': report, 'local_path': local_path})
if items:
with ThreadPoolExecutor(max_workers=workers) as executor:
futures = {executor.submit(self._upload_report, item): item for item in items}
for future in as_completed(futures):
result = future.result()
if result['success']:
stats['migrated'] += 1
else:
stats['errors'] += 1
processed += len(batch_docs)
self._print_progress(processed, total, start_time, stats)
total_time = time.time() - start_time
self.stdout.write(f"\n ✅ Completado en {total_time/60:.1f} minutos")
return stats
def _upload_report(self, item):
"""Sube un reporte directamente a MinIO"""
try:
report = item['report']
local_path = item['local_path']
filename = local_path.name
filters = report.filters or {}
org_id = filters.get('organizacion_id', 'unknown')
object_name = f"org_{org_id}/reports/{filename}"
self.client.fput_object(
bucket_name=self.bucket_name,
object_name=object_name,
file_path=str(local_path)
)
report.file = object_name
report.save(update_fields=['file'])
return {'success': True, 'id': report.id}
except Exception as e:
return {'success': False, 'id': report.id, 'error': str(e)}
def _print_progress(self, processed, total, start_time, stats):
"""Imprime el progreso actual"""
elapsed = time.time() - start_time
rate = processed / elapsed if elapsed > 0 else 0
pct = processed * 100 / total if total > 0 else 0
self.stdout.write(
f" 📊 {processed}/{total} ({pct:.1f}%) | "
f"{rate:.0f} docs/seg | "
f"{stats['migrated']} | "
f"⚠️ {stats['not_found']} | "
f"{stats['errors']}"
)

View File

@@ -19,7 +19,7 @@ def trigger_notificacion(sender, instance, created, **kwargs):
for usuario in usuarios_org:
# Notificar solo a importadores cuyo RFC coincide
if (usuario.is_importador or usuario.groups.filter(name='Importador').exists()):
if usuario.rfc == instance.pedimento.contribuyente:
if instance.pedimento.contribuyente in usuario.rfc.all():
Notificacion.objects.create(
tipo=tipo_info,
dirigido=usuario,

View File

@@ -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)

View File

@@ -17,6 +17,14 @@ class DocumentSerializer(serializers.ModelSerializer):
read_only_fields = ('id', 'size', 'extension', 'created_at', 'updated_at', 'pedimento_numero')
def get_pedimento_numero(self, obj):
# Si es un diccionario (durante create)
if isinstance(obj, dict):
pedimento = obj.get('pedimento')
if pedimento and hasattr(pedimento, 'pedimento_app'):
return pedimento.pedimento_app
return None
# Si es una instancia del modelo (durante retrieve/list)
if obj.pedimento:
return obj.pedimento.pedimento_app
return None
@@ -28,9 +36,19 @@ class DocumentSerializer(serializers.ModelSerializer):
return value
def get_fuente_nombre(self, obj):
# Método 1: Si la fuente está precargada con select_related
if obj.fuente:
return obj.fuente.nombre
"""Obtiene el nombre de la fuente de forma segura"""
if isinstance(obj, dict):
fuente = obj.get('fuente')
if fuente and hasattr(fuente, 'nombre'):
return fuente.nombre
return "Desconocido"
try:
if obj.fuente:
return obj.fuente.nombre
except AttributeError:
pass
return "Desconocido"
class FuenteSerializer(serializers.ModelSerializer):

View File

@@ -1,12 +1,16 @@
from django.urls import reverse
from django.test import TestCase
from rest_framework.test import APITestCase, APIClient
from rest_framework import status
from django.core.files.uploadedfile import SimpleUploadedFile
from unittest.mock import patch, MagicMock
from api.organization.models import Organizacion, UsoAlmacenamiento
from api.cuser.models import CustomUser
from api.customs.models import Pedimento
from .models import Document
from api.licence.models import Licencia
from api.customs.views import is_same_document, get_clean_base_filename
from .models import Document, DocumentType
import io
class DocumentViewSetTests(APITestCase):
@@ -95,3 +99,177 @@ class DocumentViewSetTests(APITestCase):
url = reverse('descargar-documento', args=[doc.id])
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
# ---------------------------------------------------------------------------
# Tests unitarios para las funciones helper de comparación de documentos
# ---------------------------------------------------------------------------
class DocumentNameHelperTests(TestCase):
"""Verifica que get_clean_base_filename e is_same_document manejan
correctamente el sufijo UUID de 8 chars que añade storage_service."""
def test_strips_uuid_suffix(self):
self.assertEqual(get_clean_base_filename('informe_a1b2c3d4.pdf'), 'informe')
def test_no_suffix_unchanged(self):
self.assertEqual(get_clean_base_filename('informe.pdf'), 'informe')
def test_is_same_document_matches_stored_uuid_name(self):
"""El archivo guardado tiene sufijo, el nuevo no — deben coincidir."""
doc = MagicMock()
doc.archivo.name = 'org_1/documents/24-01-3420-1234567/informe_a1b2c3d4.pdf'
doc.extension = 'pdf'
self.assertTrue(is_same_document(doc, 'informe.pdf'))
def test_is_same_document_different_name_no_match(self):
doc = MagicMock()
doc.archivo.name = 'org_1/documents/ped/informe_a1b2c3d4.pdf'
doc.extension = 'pdf'
self.assertFalse(is_same_document(doc, 'otro.pdf'))
def test_is_same_document_different_extension_no_match(self):
doc = MagicMock()
doc.archivo.name = 'org_1/documents/ped/informe_a1b2c3d4.pdf'
doc.extension = 'pdf'
self.assertFalse(is_same_document(doc, 'informe.xml'))
def test_both_clean_names_equal(self):
"""Dos archivos con UUID distintos pero mismo nombre base deben coincidir."""
doc = MagicMock()
doc.archivo.name = 'org_1/documents/ped/pedimento_a1b2c3d4.xml'
doc.extension = 'xml'
self.assertTrue(is_same_document(doc, 'pedimento_b5c6d7e8.xml'))
# ---------------------------------------------------------------------------
# Tests de integración para bulk-upload (DocumentViewSet.bulk_upload)
# ---------------------------------------------------------------------------
class BulkUploadReplaceTests(APITestCase):
"""Verifica que bulk-upload reemplaza documentos existentes en vez de duplicar
y que no quedan archivos residuales en el storage."""
def setUp(self):
self.licencia = Licencia.objects.create(nombre="Lic100GB", almacenamiento=100)
self.org = Organizacion.objects.create(
nombre="OrgBulkUpload",
licencia=self.licencia,
is_active=True,
is_verified=True,
)
self.user = CustomUser.objects.create_user(
username="bulkuploaduser", password="pass", organizacion=self.org
)
self.pedimento = Pedimento.objects.create(
organizacion=self.org,
pedimento="1234567",
pedimento_app="24-01-3420-1234567",
)
self.doc_type = DocumentType.objects.get_or_create(nombre="Documento General")[0]
self.url = reverse("Document-bulk-upload")
self.client.force_authenticate(user=self.user)
def _post_file(self, filename, content=b"contenido de prueba"):
archivo = SimpleUploadedFile(filename, content, content_type="application/pdf")
return self.client.post(
self.url,
{"pedimento_id": str(self.pedimento.id), "files": [archivo]},
format="multipart",
)
@patch("api.record.views.storage_service")
def test_new_file_creates_document(self, mock_st):
"""Subir un archivo nuevo crea exactamente un Document."""
mock_st.save_document.return_value = "org_1/documents/ped/informe_a1b2c3d4.pdf"
response = self._post_file("informe.pdf")
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(Document.objects.filter(pedimento=self.pedimento).count(), 1)
mock_st.delete_file.assert_not_called()
@patch("api.record.views.storage_service")
def test_duplicate_replaces_not_creates(self, mock_st):
"""Re-subir el mismo archivo debe actualizar el Document existente,
no crear uno nuevo."""
old_path = "org_1/documents/24-01-3420-1234567/informe_a1b2c3d4.pdf"
old_doc = Document.objects.create(
organizacion=self.org,
pedimento=self.pedimento,
document_type=self.doc_type,
archivo=old_path,
size=500,
extension="pdf",
)
new_path = "org_1/documents/24-01-3420-1234567/informe_b5c6d7e8.pdf"
mock_st.save_document.return_value = new_path
mock_st.delete_file.return_value = True
response = self._post_file("informe.pdf", b"contenido actualizado")
self.assertIn(response.status_code, [status.HTTP_201_CREATED, status.HTTP_207_MULTI_STATUS])
docs = Document.objects.filter(pedimento=self.pedimento)
# Un único Document — sin duplicados
self.assertEqual(docs.count(), 1)
# Es el mismo registro (mismo UUID)
self.assertEqual(docs.first().id, old_doc.id)
# El campo archivo fue actualizado
old_doc.refresh_from_db()
self.assertEqual(old_doc.archivo.name, new_path)
@patch("api.record.views.storage_service")
def test_replace_deletes_old_storage_file(self, mock_st):
"""Al reemplazar, delete_file debe llamarse con la ruta del archivo viejo."""
old_path = "org_1/documents/24-01-3420-1234567/informe_a1b2c3d4.pdf"
Document.objects.create(
organizacion=self.org,
pedimento=self.pedimento,
document_type=self.doc_type,
archivo=old_path,
size=500,
extension="pdf",
)
mock_st.save_document.return_value = "org_1/documents/24-01-3420-1234567/informe_b5c6d7e8.pdf"
mock_st.delete_file.return_value = True
self._post_file("informe.pdf")
mock_st.delete_file.assert_called_once_with(old_path)
@patch("api.record.views.storage_service")
def test_different_filename_creates_new_document(self, mock_st):
"""Archivo con nombre diferente debe crear un Document adicional."""
Document.objects.create(
organizacion=self.org,
pedimento=self.pedimento,
document_type=self.doc_type,
archivo="org_1/documents/ped/informe_a1b2c3d4.pdf",
size=500,
extension="pdf",
)
mock_st.save_document.return_value = "org_1/documents/ped/otro_b5c6d7e8.pdf"
self._post_file("otro.pdf")
self.assertEqual(Document.objects.filter(pedimento=self.pedimento).count(), 2)
mock_st.delete_file.assert_not_called()
@patch("api.record.views.storage_service")
def test_multiple_files_no_cross_replacement(self, mock_st):
"""Subir dos archivos distintos en la misma petición crea dos Documents."""
mock_st.save_document.side_effect = [
"org_1/documents/ped/a_a1b2c3d4.pdf",
"org_1/documents/ped/b_a1b2c3d4.pdf",
]
archivos = [
SimpleUploadedFile("a.pdf", b"contenido a", content_type="application/pdf"),
SimpleUploadedFile("b.pdf", b"contenido b", content_type="application/pdf"),
]
self.client.post(
self.url,
{"pedimento_id": str(self.pedimento.id), "files": archivos},
format="multipart",
)
self.assertEqual(Document.objects.filter(pedimento=self.pedimento).count(), 2)
mock_st.delete_file.assert_not_called()

View File

@@ -24,6 +24,7 @@ from rest_framework.decorators import action
from datetime import timedelta
from django.utils import timezone
from django.db.models import Q
from api.utils.storage_service import storage_service
from core.permissions import (
IsSameOrganization,
@@ -156,11 +157,10 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
def get_queryset(self):
queryset = self.get_queryset_filtrado_por_organizacion()
modulo_efc = self.request.query_params.get('modulo')
if modulo_efc:
if modulo_efc == 'expedientes-detalle-pedimentos':
queryset = queryset.exclude(document_type_id__in=['1','2','3','4','5','6','7','8','9','10'])
queryset = queryset.exclude(document_type_id__in=['1','2','3','4','5','6','7','8','9','10','25','23','21','19','17','15','13','16'])
# Filtro personalizado por document_type
# document_type = self.request.query_params.get('document_type')
# if document_type:
@@ -252,14 +252,34 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
try:
# Guardar documento y actualizar espacio atómicamente
documento = serializer.save(
pedimento = serializer.validated_data.get('pedimento')
pedimento_app = pedimento.pedimento_app if pedimento else None
documento = Document.objects.create(
document_type=document_type,
organizacion=organizacion,
pedimento=pedimento,
size=archivo.size,
extension=archivo.name.split('.')[-1].lower()
)
ruta = storage_service.save_document(
file=archivo,
organizacion_id=organizacion.id,
pedimento_app=pedimento_app,
metadata={'source': 'document_create'}
)
if ruta:
documento.archivo = ruta
documento.save()
# si no agrego esto, el proceso no retorna todos los campos necesarios como id, si lo agrega a minIO pero no
# actualiza su status.
serializer.instance = documento
else:
documento.delete()
raise ValidationError({"archivo": "Error al guardar el archivo"})
except Exception as e:
# Guardar documento y actualizar espacio atómicamente
documento = serializer.save(
@@ -300,17 +320,45 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
}, code=status.HTTP_400_BAD_REQUEST)
# Actualizar documento y espacio
serializer.save(size=new_file.size)
uso.espacio_utilizado = nuevo_espacio_utilizado
uso.save()
if instance.archivo:
ruta_anterior = str(instance.archivo)
storage_service.delete_file(ruta_anterior)
pedimento = instance.pedimento
pedimento_app = pedimento.pedimento_app if pedimento else None
ruta = storage_service.save_document(
file=new_file,
organizacion_id=organizacion.id,
pedimento_app=pedimento_app,
metadata={'source': 'document_update'}
)
if ruta:
instance.archivo = ruta
instance.size = new_file.size
instance.extension = new_file.name.split('.')[-1].lower()
instance.save()
uso.espacio_utilizado = nuevo_espacio_utilizado
uso.save()
else:
raise ValidationError({"archivo": "Error al actualizar el archivo"})
else:
serializer.save()
def perform_destroy(self, instance):
from api.utils.storage_service import storage_service
if instance.archivo:
ruta = str(instance.archivo)
storage_service.delete_file(ruta)
# Restar el espacio al eliminar
uso = UsoAlmacenamiento.objects.get(organizacion=instance.organizacion)
uso.espacio_utilizado -= instance.size
uso.save()
instance.delete()
@action(detail=False, methods=['get'], url_path='vu-documentos-errores')
@@ -508,11 +556,10 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
archivos_eliminados = 0
for doc in existing_documents:
try:
# Eliminar archivo físico
if doc.archivo and doc.archivo.storage.exists(doc.archivo.name):
doc.archivo.delete(save=False) # save=False para no intentar guardar el modelo
# Eliminar registro de la base de datos
if doc.archivo:
ruta = str(doc.archivo)
storage_service.delete_file(ruta)
doc.delete()
archivos_eliminados += 1
except Exception as e:
@@ -700,13 +747,13 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
pass
# Eliminar los documentos
archivos_eliminados = 0
for doc in existing_documents:
archivos_eliminados = 0
try:
# Eliminar archivo físico
if doc.archivo and doc.archivo.storage.exists(doc.archivo.name):
doc.archivo.delete(save=False) # save=False para no intentar guardar el modelo
if doc.archivo:
ruta = str(doc.archivo)
storage_service.delete_file(ruta)
# Eliminar registro de la base de datos
doc.delete()
archivos_eliminados += 1
@@ -899,13 +946,13 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
pass
# Eliminar los documentos
archivos_eliminados = 0
for doc in existing_documents:
archivos_eliminados = 0
try:
# Eliminar archivo físico
if doc.archivo and doc.archivo.storage.exists(doc.archivo.name):
doc.archivo.delete(save=False) # save=False para no intentar guardar el modelo
if doc.archivo:
ruta = str(doc.archivo)
storage_service.delete_file(ruta)
# Eliminar registro de la base de datos
doc.delete()
archivos_eliminados += 1
@@ -1099,13 +1146,11 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
# Eliminar los documentos
archivos_eliminados = 0
for doc in existing_documents:
try:
# Eliminar archivo físico
if doc.archivo and doc.archivo.storage.exists(doc.archivo.name):
doc.archivo.delete(save=False) # save=False para no intentar guardar el modelo
# Eliminar registro de la base de datos
if doc.archivo:
ruta = str(doc.archivo)
storage_service.delete_file(ruta)
doc.delete()
archivos_eliminados += 1
except Exception as e:
@@ -1233,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 = []
@@ -1278,10 +1329,16 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
},
"codigo": "bulk_storage_limit_exceeded"
}, status=status.HTTP_400_BAD_REQUEST)
# Cargar documentos existentes del pedimento para detectar y reemplazar duplicados
existing_docs = list(Document.objects.filter(
pedimento_id=pedimento_id,
organizacion=organizacion
))
# Procesar cada archivo
espacio_usado_temp = espacio_inicial
for file in files:
try:
# Validaciones por archivo
@@ -1289,37 +1346,81 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
failed_files.append("archivo_sin_nombre")
errors.append("Archivo sin nombre detectado")
continue
# Obtener extensión del archivo
extension = file.name.split('.')[-1].lower() if '.' in file.name else ''
# Crear el documento
document = Document.objects.create(
organizacion=organizacion,
pedimento_id=pedimento_id,
document_type=document_type,
archivo=file,
size=file.size,
extension=extension
)
# Detectar si ya existe un documento con el mismo nombre base + extensión.
# storage_service agrega un sufijo UUID de 8 chars al guardar, hay que ignorarlo.
new_name_base = re.sub(r'_[a-zA-Z0-9]{8}$', '', os.path.splitext(file.name)[0]).lower().strip('_')
existing_doc = None
for doc in existing_docs:
if doc.archivo:
doc_basename = os.path.basename(doc.archivo.name)
doc_base = re.sub(r'_[a-zA-Z0-9]{8}$', '', os.path.splitext(doc_basename)[0]).lower().strip('_')
doc_ext = (doc.extension or '').lower()
if new_name_base == doc_base and extension == doc_ext:
existing_doc = doc
break
if existing_doc:
# Reemplazar archivo del documento existente
if existing_doc.archivo:
storage_service.delete_file(existing_doc.archivo.name)
ruta = storage_service.save_document(
file=file,
organizacion_id=organizacion.id,
pedimento_app=pedimento.pedimento_app,
metadata={'source': 'bulk_upload_replace'}
)
if ruta:
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}")
document = existing_doc
else:
# Crear nuevo documento
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': 'bulk_upload'}
)
if ruta:
document.archivo = ruta
document.save()
else:
document.delete()
raise Exception(f"Error al guardar archivo: {file.name}")
# Actualizar espacio usado
espacio_usado_temp += file.size
total_space_used += file.size
uploaded_documents.append({
"id": str(document.id),
"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:
failed_files.append(file.name)
errors.append(f"Error al procesar {file.name}: {str(e)}")
continue
# Actualizar el uso de almacenamiento final
uso.espacio_utilizado = espacio_usado_temp
uso.save()
@@ -1586,11 +1687,23 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
organizacion=organizacion,
pedimento_id=pedimento_id,
document_type=document_type,
archivo=file,
size=file.size,
fuente_id=7,
extension=extension
)
ruta = storage_service.save_document(
file=file,
organizacion_id=organizacion.id,
pedimento_app=pedimento.pedimento_app,
metadata={'source': 'bulk_upload'}
)
if ruta:
document.archivo = ruta
document.save()
else:
document.delete()
raise Exception(f"Error al guardar archivo: {file.name}")
# Actualizar espacio usado
espacio_usado_temp += file.size
@@ -1644,8 +1757,267 @@ 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)]
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
serializer_class = DocumentSerializer
model = Document
my_tags = ['Documents']
@@ -1654,6 +2026,10 @@ class ProtectedDocumentDownloadView(APIView, DocumentosFiltradosMixin):
return self.get_queryset_filtrado_por_organizacion()
def get(self, request, pk):
import tempfile
import os
from api.utils.storage_service import storage_service
if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'):
raise Http404("Usuario no autenticado")
@@ -1662,21 +2038,39 @@ class ProtectedDocumentDownloadView(APIView, DocumentosFiltradosMixin):
except Document.DoesNotExist:
raise Http404("Documento no encontrado")
# Verifica que el usuario pertenece a la organización del documento
if not request.user.is_superuser:
if doc.organizacion != request.user.organizacion:
raise Http404("No autorizado")
if not doc.archivo:
raise Http404("Documento sin archivo asociado")
ruta = str(doc.archivo)
if self.request.user.is_superuser:
return FileResponse(doc.archivo.open('rb'))
if doc.organizacion != request.user.organizacion:
raise Http404("No autorizado")
return FileResponse(doc.archivo.open('rb'))
with tempfile.NamedTemporaryFile(delete=False) as tmp:
tmp_path = tmp.name
success = storage_service.download_file(ruta, tmp_path)
if not success:
raise Http404("No se pudo descargar el archivo")
filename = os.path.basename(ruta)
response = FileResponse(open(tmp_path, 'rb'),as_attachment=True,filename=filename)
import atexit
atexit.register(lambda: os.unlink(tmp_path) if os.path.exists(tmp_path) else None)
return response
class BulkDownloadZipView(APIView):
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
my_tags = ['Documents']
def post(self, request):
import tempfile
import os
from api.utils.storage_service import storage_service
if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'):
return Response({"error": "Usuario no autenticado o sin organización"}, status=401)
@@ -1695,22 +2089,87 @@ class BulkDownloadZipView(APIView):
return Response({"error": "Uno o más documentos no existen o no pertenecen a su organización."}, status=404)
buffer = BytesIO()
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
for doc in docs:
# Usar solo el nombre del archivo sin descripcion
file_name = slugify(doc.archivo.name.rsplit('/', 1)[-1].rsplit('.', 1)[0])
ext = doc.archivo.name.split('.')[-1]
zip_name = f"{file_name}.{ext}"
doc.archivo.open('rb')
zip_file.writestr(zip_name, doc.archivo.read())
doc.archivo.close()
missing_files = []
temp_files = [] # Para limpiar después
files_found = []
buffer.seek(0)
safe_name = slugify(pedimento_nombre)
response = HttpResponse(buffer, content_type='application/zip')
response['Content-Disposition'] = f'attachment; filename={safe_name or "documentos"}.zip'
return response
try:
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
for doc in docs:
if not doc.archivo:
missing_files.append(f"{doc.id} (sin archivo)")
continue
ruta = str(doc.archivo)
# ============ DETECTAR TIPO DE RUTA ============
is_minio = ruta.startswith('org_')
if is_minio:
# Verificar en MinIO
if not storage_service.file_exists(ruta):
missing_files.append(f"{doc.id} ({ruta})")
continue
else:
# Verificar en sistema local
from pathlib import Path
from django.conf import settings
full_path = Path(settings.MEDIA_ROOT) / ruta
if not full_path.exists():
missing_files.append(f"{doc.id} ({ruta})")
continue
with tempfile.NamedTemporaryFile(delete=False, suffix='.tmp') as tmp:
tmp_path = tmp.name
temp_files.append(tmp_path)
if is_minio:
success = storage_service.download_file(ruta, tmp_path)
else:
import shutil
full_path = Path(settings.MEDIA_ROOT) / ruta
try:
shutil.copy2(full_path, tmp_path)
success = True
except Exception as e:
success = False
if not success:
missing_files.append(f"{doc.id} ({ruta})")
continue
files_found.append(f"{doc.id} ({ruta})")
file_name = slugify(ruta.rsplit('/', 1)[-1].rsplit('.', 1)[0])
ext = ruta.split('.')[-1] if '.' in ruta else ''
zip_name = f"{file_name}.{ext}" if ext else file_name
with open(tmp_path, 'rb') as f:
zip_file.writestr(zip_name, f.read())
buffer.seek(0)
safe_name = slugify(pedimento_nombre)
response = HttpResponse(buffer, content_type='application/zip')
response['Content-Disposition'] = f'attachment; filename={safe_name or "documentos"}.zip'
if missing_files:
response['X-Missing-Files'] = ', '.join(missing_files[:5]) # Primeros 5
response['Access-Control-Expose-Headers'] = 'X-Missing-Files'
return response
except Exception as e:
return Response(
{"error": f"Error al crear el archivo ZIP: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
finally:
for tmp_path in temp_files:
try:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
except Exception as e:
logger.warning(f"No se pudo eliminar archivo temporal {tmp_path}: {e}")
class GetFuenteView(APIView):
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
@@ -1745,7 +2204,7 @@ class DocumentTypeView(APIView):
return Response(serializer.data, status=200)
class ExpedienteZipDownloadView(APIView, DocumentosFiltradosMixin):
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
my_tags = ['Documents']
def post(self, request):
@@ -1753,6 +2212,10 @@ class ExpedienteZipDownloadView(APIView, DocumentosFiltradosMixin):
Descarga todos los documentos de un pedimento (o filtrados) en un ZIP.
Body: { "pedimento_id": "<uuid>" }
"""
import tempfile
import os
from api.utils.storage_service import storage_service
pedimento_id = request.data.get('pedimento_id')
if not pedimento_id:
return Response({"error": "Falta pedimento_id"}, status=status.HTTP_400_BAD_REQUEST)
@@ -1774,49 +2237,73 @@ class ExpedienteZipDownloadView(APIView, DocumentosFiltradosMixin):
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
missing_files = []
files_found = []
temp_files = []
try:
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
for doc in docs:
if not doc.archivo:
missing_files.append(f"{doc.id} (sin archivo)")
continue
ruta = str(doc.archivo)
if not storage_service.file_exists(ruta):
missing_files.append(f"{doc.id} ({ruta})")
continue
with tempfile.NamedTemporaryFile(delete=False, suffix='.tmp') as tmp:
tmp_path = tmp.name
temp_files.append(tmp_path)
success = storage_service.download_file(ruta, tmp_path)
if not success:
missing_files.append(f"{doc.id} ({ruta})")
continue
files_found.append(f"{doc.id} ({ruta})")
nombre_base = ruta.rsplit('/', 1)[-1]
file_name = slugify(nombre_base.rsplit('.', 1)[0])
ext = nombre_base.split('.')[-1] if '.' in nombre_base else ''
name_inside_zip = f"{file_name}.{ext}" if ext else file_name
with open(tmp_path, 'rb') as f:
zip_file.writestr(name_inside_zip, f.read())
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 se encontraron documentos descargables para el pedimento: {pedimento.pedimento_app}"},
status=status.HTTP_404_NOT_FOUND
)
if missing_files:
response['X-Missing-Files-Count'] = str(len(missing_files))
response['Access-Control-Expose-Headers'] = 'X-Missing-Files-Count'
return response
except Exception as e:
return Response(
{"error": f"Error al crear el archivo ZIP: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
finally:
for tmp_path in temp_files:
try:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
except Exception as e:
logger.warning(f"No se pudo eliminar archivo temporal {tmp_path}: {e}")
class MultiPedimentoZipDownloadView(APIView):
permission_classes = [IsAuthenticated & (IsSuperUser | IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper)]
@@ -1905,49 +2392,43 @@ class PedimentoDocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
queryset = self.get_queryset_filtrado_por_organizacion()
pedimento_id = self.request.query_params.get('pedimento')
# Obtener el pedimento primero para usar su organización
# Validar que el pedimento existe
from api.customs.models import Pedimento
try:
pedimento = Pedimento.objects.get(id=pedimento_id)
except Pedimento.DoesNotExist:
return Response(
{"error": "Pedimento no encontrado"},
status=status.HTTP_404_NOT_FOUND
)
return Document.objects.none() # Retornar queryset vacío
# Tipos de documento permitidos (fijos en código, Pedimento completo y remesas)
TIPOS_PERMITIDOS = ['2', '3'] # <-- Ajusta aquí tus tipos
# Filtrar SOLO por pedimento
queryset = queryset.filter(pedimento_id=pedimento_id)
# Tipos de documento permitidos (fijos: 2 y 3)
TIPOS_PERMITIDOS = ['2', '3']
tipo_documento = self.request.query_params.get('document_type')
if tipo_documento:
if tipo_documento == '2':
queryset = queryset.filter(archivo__startswith=f'documents/vu_PC_{pedimento.pedimento_app}.xml')
elif tipo_documento == '3':
queryset = queryset.filter(archivo__startswith=f'documents/vu_RM_{pedimento.pedimento_app}.xml')
else:
queryset = queryset.filter(archivo__startswith=f'documents/NOTFOUND_{pedimento.pedimento_app}.xml')
# Si se especifica tipo, filtrar por ese tipo (si está en permitidos)
if tipo_documento in TIPOS_PERMITIDOS:
queryset = queryset.filter(document_type_id=tipo_documento)
else:
# Filtrar por tipos permitidos
# queryset = queryset.filter(document_type_id__in=TIPOS_PERMITIDOS)
queryset = queryset.filter(
Q(archivo__startswith=f'documents/vu_PC_{pedimento.pedimento_app}.xml') |
Q(archivo__startswith=f'documents/vu_RM_{pedimento.pedimento_app}.xml')
)
# Si no se especifica, filtrar por los tipos permitidos
queryset = queryset.filter(document_type_id__in=TIPOS_PERMITIDOS)
# Filtros adicionales
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)
queryset = queryset.filter(created_at__date=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
class TriggerPedimentoCompletoView(APIView):
"""
Endpoint interno para disparar la descarga de pedimento completo

View File

@@ -16,7 +16,8 @@ class ReportDocument(models.Model):
user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, related_name='report_documents')
filters = models.JSONField(blank=True, null=True)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
file = models.FileField(upload_to='reports/', blank=True, null=True)
# file = models.FileField(upload_to='reports/', blank=True, null=True)
file = models.CharField(max_length=500, blank=True, null=True)
report_type = models.CharField(max_length=30, choices=TYPE_REPORT, default='cumplimiento')
error_message = models.TextField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)

View File

@@ -1,6 +1,8 @@
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
@@ -10,6 +12,7 @@ from api.record.models import Document
import csv
import os
from django.conf import settings
from django.core.files.uploadedfile import SimpleUploadedFile
@shared_task
def generate_report_document(report_id):
@@ -46,15 +49,19 @@ def generate_report_document(report_id):
filename = f"{filename}.csv" if not filename.endswith('.csv') else filename
else:
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 open(file_path, 'w', newline='', encoding='utf-8') as f:
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.csv', encoding='utf-8', newline='') as f:
tmp_path = f.name
# Escribir CSV en archivo temporal
with open(tmp_path, 'w', newline='', encoding='utf-8') as f:
writer = csv.writer(f)
headers = [
'aduana', 'patente', 'regimen', 'pedimento', 'pedimento_app', 'clave_pedimento',
'tipo_operacion_id', 'contribuyente_id', 'tipo_documento', 'numero_documento', 'estado', 'acuse_estado'
]
writer.writerow(headers)
for ped in pedimentos:
for cove in Cove.objects.filter(pedimento=ped):
writer.writerow([
@@ -74,12 +81,43 @@ def generate_report_document(report_id):
ped.clave_pedimento, ped.tipo_operacion_id, ped.contribuyente_id,
'PARTIDA', partida.numero_partida, partida.descargado, ''
])
# Guardar el archivo en el modelo
with open(file_path, 'rb') as f:
report.file.save(filename, ContentFile(f.read()), save=True)
report.status = 'ready'
# ============ NUEVO: Guardar en MinIO ============
# Leer archivo temporal
with open(tmp_path, 'rb') as f:
file_content = f.read()
# Crear UploadedFile
uploaded_file = SimpleUploadedFile(
name=filename,
content=file_content,
content_type='text/csv'
)
# Guardar en storage
ruta = storage_service.save_report(
file=uploaded_file,
organizacion_id=filters.get('organizacion_id'),
metadata={
'report_id': str(report.id),
'report_type': 'cumplimiento',
'user_id': str(report.user.id) if report.user else None
}
)
if ruta:
report.file = ruta
report.status = 'ready'
else:
report.status = 'error'
report.error_message = 'Error al guardar el archivo en storage'
# Limpiar temporal
os.unlink(tmp_path)
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)
@@ -88,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'])
@@ -183,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 = []
@@ -239,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
@@ -269,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'])
if report:
report.status = 'error'
report.error_message = str(e)
report.finished_at = timezone.now()
report.save(update_fields=['status', 'error_message', 'finished_at'])

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,13 @@
from api.reports.models import ReportDocument
from api.reports.tasks.report_document import generate_report_document, generate_report_control_pedimento
from django.http import FileResponse
from api.utils.storage_service import storage_service
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
import tempfile
import os
import atexit
@api_view(['GET'])
@permission_classes([IsAuthenticated])
@@ -71,7 +75,9 @@ def table_summary(request):
"report_id": report.id,
"status": report.status,
"created_at": report.created_at,
"download_url": report.file.url if report.file else None
# "download_url": report.file.url if report.file else None
"download_url": storage_service.get_file_url(report.file) if report.file else None
}, status=202)
@api_view(['GET'])
@@ -85,7 +91,9 @@ def report_document_status(request, report_id):
"created_at": report.created_at,
"finished_at": report.finished_at,
"error_message": report.error_message,
"download_url": report.file.url if report.file else None
# "download_url": report.file.url if report.file else None
"download_url": storage_service.get_file_url(report.file) if report.file else None
}
return Response(data)
except ReportDocument.DoesNotExist:
@@ -103,7 +111,8 @@ def report_document_list(request):
"created_at": r.created_at,
"finished_at": r.finished_at,
"error_message": r.error_message,
"download_url": r.file.url if r.file else None
# "download_url": r.file.url if r.file else None
"download_url": storage_service.get_file_url(r.file) if r.file else None
}
for r in reports
]
@@ -116,8 +125,22 @@ def report_document_download(request, report_id):
report = ReportDocument.objects.get(id=report_id, user=request.user)
if not report.file:
return Response({"error": "El archivo aún no está disponible"}, status=404)
response = FileResponse(report.file.open('rb'), as_attachment=True, filename=report.file.name)
ruta = str(report.file)
with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:
tmp_path = tmp.name
success = storage_service.download_file(ruta, tmp_path)
if not success:
return Response({"error": "No se pudo descargar el archivo"}, status=500)
filename = os.path.basename(ruta)
response = FileResponse(open(tmp_path, 'rb'),as_attachment=True,filename=filename)
atexit.register(lambda: os.unlink(tmp_path) if os.path.exists(tmp_path) else None)
return response
except ReportDocument.DoesNotExist:
return Response({"error": "Reporte no encontrado"}, status=404)

View File

@@ -8,7 +8,8 @@ class TaskFilter(filters.FilterSet):
timestamp_gte = filters.DateTimeFilter(field_name='timestamp', lookup_expr='gte')
timestamp_lte = filters.DateTimeFilter(field_name='timestamp', lookup_expr='lte')
status = filters.CharFilter(field_name='status')
organizacion = filters.UUIDFilter(field_name='organizacion__id') # Cambiado a relación directa
class Meta:
model = Task
fields = ['servicio', 'pedimento_app', 'pedimento', 'timestamp_gte', 'timestamp_lte', 'status']
fields = ['servicio', 'pedimento_app', 'pedimento', 'timestamp_gte', 'timestamp_lte', 'status', 'organizacion']

View File

@@ -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/<str:task_id>/', TaskStatusView.as_view(), name='task-status'),
]

View File

@@ -4,6 +4,7 @@ from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.pagination import PageNumberPagination
from api.logger.mixins import LoggingMixin
from mixins.filtrado_organizacion import OrganizacionFiltradaMixin, ProcesosPorOrganizacionMixin
from .models import Task
from .serializers import TaskSerializer
from .filters import TaskFilter
@@ -22,7 +23,7 @@ class TaskPagination(PageNumberPagination):
page_size_query_param = 'page_size'
max_page_size = 100
class TaskViewSet(LoggingMixin,viewsets.ModelViewSet):
class TaskViewSet(LoggingMixin,viewsets.ModelViewSet,OrganizacionFiltradaMixin):
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
queryset = Task.objects.select_related('pedimento', 'servicio').all()
serializer_class = TaskSerializer
@@ -32,4 +33,88 @@ class TaskViewSet(LoggingMixin,viewsets.ModelViewSet):
ordering_fields = ['timestamp']
ordering = ['-timestamp'] # ordenamiento por defecto, más reciente primero
my_tags = ['tasks']
my_tags = ['tasks']
def get_queryset(self):
"""
Filtra las tareas según la organización del usuario.
Superusuarios pueden ver todas las tareas.
"""
queryset = self.get_queryset_filtrado_por_organizacion() # Tambien filtra por importador
# if user.is_superuser:
# return self.queryset
# # return self.queryset.filter(organizacion_id=user.organizacion.id)
# 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):
permission_classes = [IsAuthenticated]
def get(self, request, task_id):
"""
Consulta el estado de una tarea Celery.
Estados posibles:
PENDING — en cola, aún no inició
STARTED — worker la tomó y está ejecutando
SUCCESS — terminó correctamente, `result` contiene el resumen
FAILURE — lanzó una excepción no capturada, `error` describe el problema
RETRY — el worker la está reintentando
"""
try:
task_result = AsyncResult(task_id)
state = task_result.state
response_data = {
'task_id': task_id,
'status': state,
'ready': task_result.ready(),
'successful': task_result.successful() if task_result.ready() else None,
}
if state == 'SUCCESS':
result = task_result.result
response_data['result'] = result
# Resumen legible cuando es auditoría masiva de organización
if isinstance(result, dict) and 'total_pedimentos' in result:
total = result.get('total_pedimentos', 0)
completados = result.get('completados', 0)
con_pendientes = result.get('con_pendientes', 0)
con_errores = result.get('con_errores', 0)
if con_pendientes == 0 and con_errores == 0:
mensaje = f'Auditoría completa — {completados}/{total} pedimentos sin pendientes'
else:
partes = []
if con_pendientes:
partes.append(f'{con_pendientes} con documentos pendientes')
if con_errores:
partes.append(f'{con_errores} con error')
mensaje = f'{completados}/{total} pedimentos completos — {", ".join(partes)}'
response_data['mensaje'] = mensaje
elif state == 'FAILURE':
response_data['error'] = str(task_result.info)
elif 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
)

143
api/utils/minio_client.py Normal file
View File

@@ -0,0 +1,143 @@
# backend/utils/minio_client.py
from datetime import timedelta
import os
from minio import Minio
from minio.error import S3Error
from django.conf import settings
from typing import Optional, BinaryIO
import logging
logger = logging.getLogger(__name__)
class MinIOClient:
"""Cliente singleton para MinIO con operaciones avanzadas"""
_instance = None
_client = None
_bucket_name = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
if self._client is None and settings.STORAGE_BACKEND == 'minio':
self._initialize_client()
def _initialize_client(self):
"""Inicializa el cliente de MinIO"""
try:
endpoint = os.getenv('MINIO_ENDPOINT', 'minio:9000')
access_key = os.getenv('MINIO_ACCESS_KEY')
secret_key = os.getenv('MINIO_SECRET_KEY')
secure = os.getenv('MINIO_SECURE', 'false').lower() == 'true'
self._client = Minio(
endpoint=endpoint,
access_key=access_key,
secret_key=secret_key,
secure=secure
)
self._bucket_name = os.environ.get('MINIO_BUCKET_NAME', 'efc-backend-dev')
# Asegurar que el bucket existe
if not self._client.bucket_exists(self._bucket_name):
self._client.make_bucket(self._bucket_name)
except Exception as e:
raise
def upload_file(
self,
object_name: str,
file_path: str = None,
file_data: BinaryIO = None,
content_type: str = None,
metadata: dict = None
) -> bool:
"""
Sube un archivo a MinIO
Args:
object_name: Ruta del objeto en el bucket (ej: 'documents/archivo.xml')
file_path: Ruta local del archivo (opcional)
file_data: Datos del archivo en memoria (opcional)
content_type: MIME type del archivo
metadata: Metadatos adicionales
Returns:
bool: True si se subió correctamente
"""
try:
if file_path:
self._client.fput_object(
bucket_name=self._bucket_name,
object_name=object_name,
file_path=file_path,
content_type=content_type,
metadata=metadata
)
elif file_data:
self._client.put_object(
bucket_name=self._bucket_name,
object_name=object_name,
data=file_data,
length=-1,
part_size=10*1024*1024, # 10MB
content_type=content_type,
metadata=metadata
)
else:
raise ValueError("You must provide file_path or file_data")
return True
except S3Error as e:
return False
def get_file_url(self, object_name: str, expires: int = 3600) -> Optional[str]:
"""Genera una URL firmada para acceder al archivo"""
try:
url = self._client.presigned_get_object(
bucket_name=self._bucket_name,
object_name=object_name,
expires=timedelta(seconds=expires)
)
# Reemplazar endpoint interno por público si está configurado
public_endpoint = os.getenv('MINIO_PUBLIC_ENDPOINT')
if public_endpoint and url:
internal_endpoint = os.getenv('MINIO_ENDPOINT', 'minio:9000')
url = url.replace(internal_endpoint, public_endpoint)
return url
except S3Error as e:
return None
def delete_file(self, object_name: str) -> bool:
"""Elimina un archivo del bucket"""
try:
self._client.remove_object(
bucket_name=self._bucket_name,
object_name=object_name
)
return True
except S3Error as e:
return False
def file_exists(self, object_name: str) -> bool:
"""Verifica si un archivo existe en el bucket"""
try:
self._client.stat_object(
bucket_name=self._bucket_name,
object_name=object_name
)
return True
except S3Error:
return False
# Singleton para uso global
minio_client = MinIOClient()

View File

@@ -0,0 +1,628 @@
# backend/utils/storage_service.py
import os
import logging
import mimetypes
import shutil
from uuid import uuid4
from typing import Optional, Union, Literal
from pathlib import Path
from enum import Enum
from django.core.files.uploadedfile import UploadedFile
from django.conf import settings
from .minio_client import minio_client
logger = logging.getLogger(__name__)
class StorageCategory(str, Enum):
"""Categorías de almacenamiento disponibles"""
DOCUMENTS = "documents"
DATASTAGE = "datastage"
REPORTS = "reports"
VUCEM_CERTS = "vucem_certs"
VUCEM_KEYS = "vucem_keys"
class StorageService:
"""
Servicio para gestionar el almacenamiento de archivos.
Estructura aislada por organización:
org_{id}/
├── documents/{pedimento_app o unknown}/
├── datastage/
├── reports/
├── vucem_certs/
└── vucem_keys/
"""
def __init__(self):
self.client = minio_client
self.storage_backend = getattr(settings, 'STORAGE_BACKEND', 'local')
self.local_media_root = getattr(settings, 'MEDIA_ROOT', 'media')
self.debug = getattr(settings, 'DEBUG', False)
def _generate_filename(self, original_filename: str) -> str:
"""Genera un nombre de archivo único para evitar colisiones"""
name, ext = os.path.splitext(original_filename)
unique_id = str(uuid4())[:8]
return f"{name}_{unique_id}{ext}"
def _get_content_type(self, filename: str) -> Optional[str]:
"""Determina el content-type basado en la extensión del archivo"""
content_type, _ = mimetypes.guess_type(filename)
return content_type
def _sanitize_folder_name(self, name: str) -> str:
"""
Sanitizar nombres de carpetas reemplazando caracteres problematicos.
Los guiones (-) son validos.
"""
invalid_chars = '<>:"/\\|?*'
for char in invalid_chars:
name = name.replace(char, '_')
return name
def _build_base_path(self, organizacion_id: Union[int, str]) -> str:
"""Construye la ruta base para una organización"""
return f"org_{organizacion_id}"
def _build_document_path(
self,
organizacion_id: Union[int, str],
filename: str,
pedimento_app: Optional[str] = None
) -> str:
"""
Construye ruta para DOCUMENTS:
org_{id}/documents/{pedimento_app o unknown}/archivo
"""
base = self._build_base_path(organizacion_id)
safe_filename = self._generate_filename(filename)
if pedimento_app:
subfolder = self._sanitize_folder_name(pedimento_app)
else:
subfolder = "unknown"
return f"{base}/{StorageCategory.DOCUMENTS.value}/{subfolder}/{safe_filename}"
def _build_generic_path(
self,
organizacion_id: Union[int, str],
filename: str,
category: StorageCategory,
subfolder: Optional[str] = None
) -> str:
"""
Construye ruta para categorías genéricas:
org_{id}/{category}/{subfolder}/{archivo}
o
org_{id}/{category}/{archivo}
"""
base = self._build_base_path(organizacion_id)
safe_filename = self._generate_filename(filename)
if subfolder:
safe_subfolder = self._sanitize_folder_name(subfolder)
return f"{base}/{category.value}/{safe_subfolder}/{safe_filename}"
else:
return f"{base}/{category.value}/{safe_filename}"
def _save_file(
self,
file: UploadedFile,
object_path: str,
metadata: Optional[dict] = None
) -> Optional[str]:
"""Guarda el archivo según el backend configurado"""
meta = metadata or {}
meta['original_filename'] = file.name
content_type = self._get_content_type(file.name)
if self.storage_backend == 'minio':
return self._save_to_minio(file, object_path, content_type, meta)
else:
return self._save_to_local(file, object_path)
def _save_to_minio(
self,
file: UploadedFile,
object_path: str,
content_type: Optional[str],
metadata: dict
) -> Optional[str]:
"""Guarda archivo en MinIO"""
try:
file.seek(0)
success = self.client.upload_file(
object_name=object_path,
file_data=file,
content_type=content_type,
metadata=metadata
)
if success:
return object_path
else:
return None
except Exception as e:
return None
def _save_to_local(self, file: UploadedFile, object_path: str) -> Optional[str]:
"""Guarda archivo en sistema local"""
try:
full_path = Path(self.local_media_root) / object_path
full_path.parent.mkdir(parents=True, exist_ok=True)
with open(full_path, 'wb+') as destination:
for chunk in file.chunks():
destination.write(chunk)
return object_path
except Exception as e:
return None
def save_document(
self,
file: UploadedFile,
organizacion_id: Union[int, str],
pedimento_app: Optional[str] = None,
metadata: Optional[dict] = None
) -> Optional[str]:
"""
Guarda un documento en la categoría 'documents'.
Args:
file: Archivo a guardar
organizacion_id: ID de la organización (obligatorio)
pedimento_app: Identificador del pedimento (opcional, ej: '24-23-1653-4003611')
metadata: Metadatos adicionales
Returns:
str: Ruta guardada o None si hay error
Ejemplo:
save_document(file, 123, '24-23-1653-4003611')
'org_123/documents/24-23-1653-4003611/documento_a1b2c3d4.xml'
"""
if not file or not organizacion_id:
return None
object_path = self._build_document_path(organizacion_id, file.name, pedimento_app)
meta = metadata or {}
meta.update({
'category': StorageCategory.DOCUMENTS.value,
'organizacion_id': str(organizacion_id),
'pedimento_app': pedimento_app if pedimento_app else 'unknown'
})
return self._save_file(file, object_path, meta)
def save_document_from_path(
self,
file_path: str,
file_name: str,
organizacion_id: Union[int, str],
pedimento_app: Optional[str] = None,
metadata: Optional[dict] = None
) -> Optional[str]:
"""
Guarda un documento desde una ruta de archivo en disco.
Útil para archivos temporales ya extraídos.
Args:
file_path: Ruta completa del archivo en disco
file_name: Nombre del archivo
organizacion_id: ID de la organización
pedimento_app: Identificador del pedimento (opcional)
metadata: Metadatos adicionales
Returns:
str: Ruta guardada o None si hay error
"""
if not file_path or not os.path.exists(file_path):
return None
if not organizacion_id:
return None
base = self._build_base_path(organizacion_id)
safe_filename = self._generate_filename(file_name)
if pedimento_app:
subfolder = self._sanitize_folder_name(pedimento_app)
else:
subfolder = "unknown"
object_path = f"{base}/{StorageCategory.DOCUMENTS.value}/{subfolder}/{safe_filename}"
# Metadatos
meta = metadata or {}
meta.update({
'category': StorageCategory.DOCUMENTS.value,
'organizacion_id': str(organizacion_id),
'pedimento_app': pedimento_app if pedimento_app else 'unknown',
'original_filename': file_name
})
content_type = self._get_content_type(file_name)
# Guardar según backend
if self.storage_backend == 'minio':
try:
self.client._client.fput_object(
bucket_name=self.client._bucket_name,
object_name=object_path,
file_path=file_path,
content_type=content_type,
metadata=meta
)
return object_path
except Exception as e:
return None
else:
try:
dest_path = Path(self.local_media_root) / object_path
dest_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(file_path, dest_path)
return object_path
except Exception as e:
return None
def save_datastage(
self,
file: UploadedFile,
organizacion_id: Union[int, str],
subfolder: Optional[str] = None,
metadata: Optional[dict] = None
) -> Optional[str]:
"""
Guarda un archivo en la categoría 'datastage' (.zip, .jar, .rar, etc.)
Args:
file: Archivo a guardar
organizacion_id: ID de la organización
subfolder: Subcarpeta opcional dentro de datastage
metadata: Metadatos adicionales
Returns:
str: Ruta guardada o None si hay error
Ejemplo:
save_datastage(file, 123)
'org_123/datastage/proceso_a1b2c3d4.zip'
"""
if not file or not organizacion_id:
return None
object_path = self._build_generic_path(
organizacion_id, file.name, StorageCategory.DATASTAGE, subfolder
)
meta = metadata or {}
meta.update({
'category': StorageCategory.DATASTAGE.value,
'organizacion_id': str(organizacion_id)
})
if subfolder:
meta['subfolder'] = subfolder
return self._save_file(file, object_path, meta)
def save_report(
self,
file: UploadedFile,
organizacion_id: Union[int, str],
subfolder: Optional[str] = None,
metadata: Optional[dict] = None
) -> Optional[str]:
"""
Guarda un reporte en la categoría 'reports' (.pdf, .xlsx, etc.)
Args:
file: Archivo a guardar
organizacion_id: ID de la organización
subfolder: Subcarpeta opcional dentro de reports (ej: 'mensuales', '2025')
metadata: Metadatos adicionales
Returns:
str: Ruta guardada o None si hay error
Ejemplo:
>>> save_report(file, 123, '2025/enero')
'org_123/reports/2025/enero/reporte_x1y2z3w4.pdf'
"""
if not file or not organizacion_id:
return None
object_path = self._build_generic_path(
organizacion_id, file.name, StorageCategory.REPORTS, subfolder
)
meta = metadata or {}
meta.update({
'category': StorageCategory.REPORTS.value,
'organizacion_id': str(organizacion_id)
})
if subfolder:
meta['subfolder'] = subfolder
return self._save_file(file, object_path, meta)
def save_vucem_cert(
self,
file: UploadedFile,
organizacion_id: Union[int, str],
metadata: Optional[dict] = None
) -> Optional[str]:
"""
Guarda un certificado VUCEM en la categoría 'vucem_certs'.
Args:
file: Archivo de certificado
organizacion_id: ID de la organización
metadata: Metadatos adicionales
Returns:
str: Ruta guardada o None si hay error
Ejemplo:
>>> save_vucem_cert(file, 123)
'org_123/vucem_certs/certificado_a1b2c3d4.cer'
"""
if not file or not organizacion_id:
return None
object_path = self._build_generic_path(
organizacion_id, file.name, StorageCategory.VUCEM_CERTS
)
meta = metadata or {}
meta.update({
'category': StorageCategory.VUCEM_CERTS.value,
'organizacion_id': str(organizacion_id)
})
return self._save_file(file, object_path, meta)
def save_vucem_key(
self,
file: UploadedFile,
organizacion_id: Union[int, str],
metadata: Optional[dict] = None
) -> Optional[str]:
"""
Guarda una llave VUCEM en la categoría 'vucem_keys'.
Args:
file: Archivo de llave
organizacion_id: ID de la organización
metadata: Metadatos adicionales
Returns:
str: Ruta guardada o None si hay error
Ejemplo:
>>> save_vucem_key(file, 123)
'org_123/vucem_keys/llave_a1b2c3d4.key'
"""
if not file or not organizacion_id:
return None
object_path = self._build_generic_path(
organizacion_id, file.name, StorageCategory.VUCEM_KEYS
)
meta = metadata or {}
meta.update({
'category': StorageCategory.VUCEM_KEYS.value,
'organizacion_id': str(organizacion_id)
})
return self._save_file(file, object_path, meta)
def save_custom(
self,
file: UploadedFile,
organizacion_id: Union[int, str],
custom_path: str,
metadata: Optional[dict] = None
) -> Optional[str]:
"""
Guarda un archivo en una ruta personalizada dentro de la organización.
Args:
file: Archivo a guardar
organizacion_id: ID de la organización
custom_path: Ruta personalizada (se antepone org_{id}/)
metadata: Metadatos adicionales
Returns:
str: Ruta guardada o None si hay error
Ejemplo:
>>> save_custom(file, 123, 'temp/procesando/archivo.xml')
'org_123/temp/procesando/archivo_a1b2c3d4.xml'
"""
if not file or not organizacion_id:
return None
base = self._build_base_path(organizacion_id)
safe_filename = self._generate_filename(file.name)
# Combinar custom_path con el nombre del archivo
if custom_path.endswith('/'):
object_path = f"{base}/{custom_path}{safe_filename}"
else:
object_path = f"{base}/{custom_path}/{safe_filename}"
meta = metadata or {}
meta.update({
'organizacion_id': str(organizacion_id),
'custom_path': custom_path
})
return self._save_file(file, object_path, meta)
def get_file_url(self, object_path: str, expires: int = 3600) -> Optional[str]:
"""
Obtiene una URL para acceder al documento.
En desarrollo, reemplaza 'minio' por 'localhost' para acceso desde el navegador.
"""
if not object_path:
return None
if self.storage_backend == 'minio':
url = self.client.get_file_url(object_path, expires)
# En desarrollo, reemplazar 'minio:9000' por 'localhost:9000'
if url and self.debug:
url = url.replace('minio:9000', 'localhost:9000')
return url
else:
return f"{settings.MEDIA_URL}{object_path}"
def delete_file(self, object_path: str) -> bool:
"""Elimina un archivo"""
if self.storage_backend == 'minio':
return self.client.delete_file(object_path)
else:
try:
full_path = Path(self.local_media_root) / object_path
if full_path.exists():
full_path.unlink()
return True
return False
except Exception as e:
return False
def file_exists(self, object_path: str) -> bool:
"""Verifica si un archivo existe (MinIO o local)"""
if not object_path:
return False
# Si la ruta empieza con 'org_', es MinIO
if object_path.startswith('org_'):
if self.storage_backend == 'minio':
return self.client.file_exists(object_path)
else:
return (Path(self.local_media_root) / object_path).exists()
else:
# Ruta local antigua (ej: 'documents/archivo.xml')
# Siempre verificar en MEDIA_ROOT
return (Path(self.local_media_root) / object_path).exists()
def download_file(self, object_path: str, destination_path: str) -> bool:
"""
Descarga un archivo de MinIO al sistema de archivos local.
"""
if not object_path:
return False
if self.storage_backend == 'minio':
try:
self.client._client.fget_object(
bucket_name=self.client._bucket_name,
object_name=object_path,
file_path=destination_path
)
return True
except Exception as e:
return False
else:
import shutil
src = Path(self.local_media_root) / object_path
if src.exists():
shutil.copy(src, destination_path)
return True
return False
def is_minio_path(self, path):
if not path:
return False
return path.startswith('org_')
# =============================================================================================================
# POR AHORA NO FUERON SOLICITADOS PERO POR EL PROBLEMA DEL 15/04/2026, CONSIDERO PRUDENTE PODER TENER ESTOS
# DOS METODOS PARA NO COMPLICARNOS EN UN FUTURO, EN CASO DE SER NECESARIOS
# =============================================================================================================
# def delete_organization_folder(self, organizacion_id: Union[int, str]) -> bool:
# """
# Elimina TODOS los archivos de una organización.
# Útil cuando un cliente se va y necesitas borrar sus datos.
# Esta operación es IRREVERSIBLE.
# """
# prefix = f"org_{organizacion_id}/"
# if self.storage_backend == 'minio':
# try:
# objects = self.client._client.list_objects(self.client._bucket_name,prefix=prefix,recursive=True)
# for obj in objects:
# self.client.delete_file(obj.object_name)
# return True
# except Exception as e:
# return False
# else:
# try:
# import shutil
# full_path = Path(self.local_media_root) / f"org_{organizacion_id}"
# if full_path.exists():
# shutil.rmtree(full_path)
# return True
# except Exception as e:
# return False
# def export_organization_files(
# self,
# organizacion_id: Union[int, str],
# output_zip_path: str
# ) -> bool:
# """
# Exporta TODOS los archivos de una organización a un ZIP.
# Útil para entregar datos a un cliente que se va.
# Args:
# organizacion_id: ID de la organización
# output_zip_path: Ruta donde guardar el ZIP
# Returns: bool
# """
# import zipfile
# from io import BytesIO
# prefix = f"org_{organizacion_id}/"
# try:
# with zipfile.ZipFile(output_zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
# if self.storage_backend == 'minio':
# objects = self.client._client.list_objects(self.client._bucket_name,prefix=prefix,recursive=True)
# for obj in objects:
# response = self.client._client.get_object(self.client._bucket_name,obj.object_name)
# data = response.read()
# zip_path = obj.object_name.replace(prefix, '', 1)
# zipf.writestr(zip_path, data)
# response.close()
# else:
# local_path = Path(self.local_media_root) / f"org_{organizacion_id}"
# if local_path.exists():
# for file_path in local_path.rglob('*'):
# if file_path.is_file():
# zip_path = str(file_path.relative_to(local_path))
# zipf.write(file_path, zip_path)
# return True
# except Exception as e:
# return False
# Singleton para uso global
storage_service = StorageService()

View File

@@ -20,8 +20,10 @@ class Vucem(models.Model):
password = models.CharField(max_length=100, help_text="Contraseña de VUCEM")
patente = models.CharField(max_length=100, unique=True, help_text="Patente de VUCEM")
efirma = models.CharField(max_length=100, blank=True, null=True,help_text="E-Firma de VUCEM")
key = models.FileField(upload_to='vucem_keys/', help_text="Llave privada de VUCEM")
cer = models.FileField(upload_to='vucem_certs/', help_text="Certificado de VUCEM")
# key = models.FileField(upload_to='vucem_keys/', help_text="Llave privada de VUCEM")
# cer = models.FileField(upload_to='vucem_certs/', help_text="Certificado de VUCEM")
key = models.CharField(max_length=500, blank=True, null=True, help_text="Llave privada de VUCEM")
cer = models.CharField(max_length=500, blank=True, null=True, help_text="Certificado de VUCEM")
is_importador = models.BooleanField(default=False, help_text="Indica si es importador")
acusecove = models.BooleanField(default=False, help_text="Indica si generara acusecove")

View File

@@ -1,5 +1,6 @@
from api.utils.storage_service import storage_service
from rest_framework import serializers
from .models import Vucem, CredencialesImportador
@@ -9,11 +10,91 @@ from .models import Vucem, CredencialesImportador
class VucemSerializer(serializers.ModelSerializer):
importadores = serializers.SerializerMethodField()
key = serializers.FileField(write_only=True, required=False, allow_null=True)
cer = serializers.FileField(write_only=True, required=False, allow_null=True)
key_download_url = serializers.SerializerMethodField(read_only=True)
cer_download_url = serializers.SerializerMethodField(read_only=True)
class Meta:
model = Vucem
fields = '__all__'
read_only_fields = ('created_at', 'updated_at', 'organizacion', 'created_by', 'updated_by')
def get_key_download_url(self, obj):
if obj.key:
return storage_service.get_file_url(obj.key)
return None
def get_cer_download_url(self, obj):
if obj.cer:
return storage_service.get_file_url(obj.cer)
return None
def create(self, validated_data):
key_file = validated_data.pop('key', None)
cer_file = validated_data.pop('cer', None)
organizacion = validated_data.get('organizacion')
vucem = super().create(validated_data)
if key_file:
ruta = storage_service.save_vucem_key(
file=key_file,
organizacion_id=organizacion.id,
metadata={'vucem_id': str(vucem.id)}
)
if ruta:
vucem.key = ruta
else:
vucem.delete()
raise serializers.ValidationError({"key": "Error al guardar la llave"})
if cer_file:
ruta = storage_service.save_vucem_cert(
file=cer_file,
organizacion_id=organizacion.id,
metadata={'vucem_id': str(vucem.id)}
)
if ruta:
vucem.cer = ruta
else:
vucem.delete()
raise serializers.ValidationError({"cer_file": "Error al guardar el certificado"})
vucem.save()
return vucem
def update(self, instance, validated_data):
key_file = validated_data.pop('key', None)
cer_file = validated_data.pop('cer', None)
organizacion = validated_data.get('organizacion', instance.organizacion)
instance = super().update(instance, validated_data)
if key_file:
if instance.key:
storage_service.delete_file(str(instance.key))
ruta = storage_service.save_vucem_key(
file=key_file,
organizacion_id=organizacion.id
)
if ruta:
instance.key = ruta
if cer_file:
if instance.cer:
storage_service.delete_file(str(instance.cer))
ruta = storage_service.save_vucem_cert(
file=cer_file,
organizacion_id=organizacion.id
)
if ruta:
instance.cer = ruta
instance.save()
return instance
def get_importadores(self, obj):
# Importar aquí para evitar importación circular
from api.customs.serializers import ImportadorSerializer

View File

@@ -1,3 +1,7 @@
import atexit
import os
import tempfile
from django.shortcuts import render
from ..organization.models import Organizacion
from rest_framework import viewsets
@@ -8,6 +12,7 @@ from rest_framework.permissions import IsAuthenticated
from rest_framework.decorators import action
from rest_framework.response import Response
from django.http import FileResponse, Http404
from api.utils.storage_service import storage_service
from .serializers import VucemSerializer, CredencialesImportadorSerializer, CredencialesImportadorSimpleSerializer
from rest_framework import serializers
@@ -79,7 +84,7 @@ class VucemView(viewsets.ModelViewSet):
elif not hasattr(self.request.user, 'organizacion') or not self.request.user.organizacion:
return queryset.none()
elif self.request.user.groups.filter(name='Importador').exists():
queryset = queryset.filter(organizacion=self.request.user.organizacion, usuario=self.request.user.rfc)
queryset = queryset.filter(organizacion=self.request.user.organizacion, usuario__in=self.request.user.rfc.all())
else:
queryset = queryset.filter(organizacion=self.request.user.organizacion)
@@ -140,25 +145,52 @@ class VucemView(viewsets.ModelViewSet):
@action(detail=True, methods=["get"], permission_classes=[IsAuthenticated])
def download_cer(self, request, pk=None):
"""
Descarga directa del archivo cer.
"""
vucem = self.get_object()
if not vucem.cer:
return Response({"detail": "No hay archivo cer disponible."}, status=404)
response = FileResponse(vucem.cer.open('rb'), as_attachment=True, filename=vucem.cer.name.split('/')[-1])
ruta = str(vucem.cer)
with tempfile.NamedTemporaryFile(delete=False) as tmp:
tmp_path = tmp.name
success = storage_service.download_file(ruta, tmp_path)
if not success:
raise Http404("No se pudo descargar el archivo")
filename = os.path.basename(ruta)
response = FileResponse(open(tmp_path, 'rb'), as_attachment=True, filename=filename)
atexit.register(lambda: os.unlink(tmp_path) if os.path.exists(tmp_path) else None)
return response
@action(detail=True, methods=["get"], permission_classes=[IsAuthenticated])
def download_key(self, request, pk=None):
"""
Descarga directa del archivo key.
"""
vucem = self.get_object()
if not vucem.key:
return Response({"detail": "No hay archivo key disponible."}, status=404)
response = FileResponse(vucem.key.open('rb'), as_attachment=True, filename=vucem.key.name.split('/')[-1])
ruta = str(vucem.key)
with tempfile.NamedTemporaryFile(delete=False) as tmp:
tmp_path = tmp.name
success = storage_service.download_file(ruta, tmp_path)
if not success:
raise Http404("No se pudo descargar el archivo")
filename = os.path.basename(ruta)
response = FileResponse(open(tmp_path, 'rb'), as_attachment=True, filename=filename)
atexit.register(lambda: os.unlink(tmp_path) if os.path.exists(tmp_path) else None)
return response
def perform_destroy(self, instance):
if instance.key:
storage_service.delete_file(str(instance.key))
if instance.cer:
storage_service.delete_file(str(instance.cer))
instance.delete()
class CredencialesImportadorViewSet(viewsets.ModelViewSet):

View File

@@ -1,8 +1,11 @@
import os
from celery import Celery
from datetime import timedelta
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
app = Celery('config')
app.config_from_object('django.conf:settings', namespace='CELERY')
# corroborar que las tareas esten programadas, se cambio el horario a hora denver
# print("Beat schedule cargado:", app.conf.beat_schedule)
app.autodiscover_tasks()

View File

@@ -27,10 +27,17 @@ import re
# Celery Beat Schedule
from celery.schedules import crontab
from config.stg.storage import *
CELERY_BEAT_SCHEDULE = {
'process_all_organizations': {
'task': 'api.customs.tasks.microservice_v2.process_all_organizations',
'schedule': crontab(hour=7, minute=1), # analizar si se requiere otra en un futuro
},
# 'process_all_organizations': {
# 'task': 'api.customs.tasks.microservice_v2.process_all_organizations',
# 'schedule': crontab(hour=11, minute=39), # analizar si se requiere otra en un futuro
# },
}
# Cargar variables de entorno desde un archivo .env
@@ -85,6 +92,7 @@ THIRD_APPS = [
]
OWN_APPS = [
'api',
'api.customs',
'api.record',
'api.organization',
@@ -280,6 +288,9 @@ else:
STATICFILES_DIRS = []
STATIC_ROOT = BASE_DIR / 'static'
if STORAGE_BACKEND == 'minio':
MEDIA_URL = f"http://{os.getenv('MINIO_ENDPOINT')}/{AWS_STORAGE_BUCKET_NAME}/"
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
@@ -300,7 +311,8 @@ DEFAULT_FROM_EMAIL = EMAIL_HOST_USER
# Configuración Celery
CELERY_BROKER_URL = os.getenv('CELERY_BROKER_URL', 'redis://redis:6379/0')
CELERY_RESULT_BACKEND = os.getenv('CELERY_RESULT_BACKEND', 'redis://redis:6379/0')
CELERY_TIMEZONE = 'America/Mexico_City'
# CELERY_TIMEZONE = 'America/Mexico_City'
CELERY_TIMEZONE = 'America/Denver'
# Configuración para procesamiento asíncrono nativo de Django
ASGI_APPLICATION = 'config.asgi.application'

29
config/stg/storage.py Normal file
View File

@@ -0,0 +1,29 @@
# backend/config/stg/storage.py
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent.parent
STORAGE_BACKEND = os.getenv('STORAGE_BACKEND', 'local')
if STORAGE_BACKEND == 'minio':
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'
AWS_ACCESS_KEY_ID = os.getenv('MINIO_ACCESS_KEY')
AWS_SECRET_ACCESS_KEY = os.getenv('MINIO_SECRET_KEY')
AWS_STORAGE_BUCKET_NAME = os.getenv('MINIO_BUCKET_NAME')
AWS_S3_ENDPOINT_URL = f"http://{os.getenv('MINIO_ENDPOINT')}"
AWS_S3_REGION_NAME = os.getenv('MINIO_REGION', 'us-east-1')
AWS_S3_USE_SSL = os.getenv('MINIO_SECURE', 'false').lower() == 'true'
AWS_DEFAULT_ACL = 'private'
AWS_LOCATION = 'documents'
AWS_S3_FILE_OVERWRITE = False
AWS_QUERYSTRING_AUTH = True
AWS_QUERYSTRING_EXPIRE = 3600 # es 1 hora
# STATICFILES_STORAGE = 'storages.backends.s3boto3.S3StaticStorage'
else:
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')

View File

@@ -62,8 +62,8 @@ class OrganizacionFiltradaMixin:
return model.objects.filter(**filtros_base)
# if hasattr(model, self.campo_contribuyente):
if self.request.user.is_authenticated and 'Importador' in grupos :
filtros_base[f"{self.campo_contribuyente}__rfc"] = self.request.user.rfc.rfc
if self.request.user.is_authenticated and 'Importador' in grupos:
filtros_base[f"{self.campo_contribuyente}__in"] = self.request.user.rfc.all()
return model.objects.filter(**filtros_base)
# Si no entra en los roles válidos
@@ -98,7 +98,7 @@ class DocumentosFiltradosMixin:
if hasattr(model, self.campo_contribuyente):
if self.request.user.is_authenticated and 'Importador' in grupos and getattr(self.request.user, 'is_importador', False):
filtros_base[f"{self.campo_contribuyente}__contribuyente"] = self.request.user.rfc
filtros_base[f"{self.campo_contribuyente}__contribuyente__in"] = self.request.user.rfc.all()
return model.objects.filter(**filtros_base)
# Si no entra en los roles válidos
@@ -133,8 +133,8 @@ class ProcesosPorOrganizacionMixin:
return model.objects.filter(**filtros_base)
if hasattr(model, self.campo_pedimento):
if self.request.user.is_authenticated and'Importador' in grupos and getattr(self.request.user, 'is_importador', False):
filtros_base[f"{self.campo_pedimento}__contribuyente"] = self.request.user.rfc
if self.request.user.is_authenticated and 'Importador' in grupos and getattr(self.request.user, 'is_importador', False):
filtros_base[f"{self.campo_pedimento}__contribuyente__in"] = self.request.user.rfc.all()
return model.objects.filter(**filtros_base)
# Si no entra en los roles válidos

View File

@@ -1,12 +1,17 @@
alembic==1.14.0
amqp==5.3.1
annotated-types==0.7.0
argon2-cffi==25.1.0
argon2-cffi-bindings==25.1.0
asgiref==3.9.1
async-timeout==5.0.1
attrs==25.3.0
billiard==4.2.1
boto3==1.42.91
botocore==1.42.91
celery==5.5.3
certifi==2025.6.15
cffi==2.0.0
channels==4.3.1
channels_redis==4.3.0
charset-normalizer==3.4.2
@@ -18,6 +23,7 @@ Django==5.2.3
django-cors-headers==4.7.0
django-filter==25.1
django-jet-reboot==1.3.10
django-storages==1.14.6
djangorestframework==3.16.0
djangorestframework_simplejwt==5.5.0
drf-yasg==1.21.10
@@ -30,12 +36,14 @@ humanize==4.12.3
idna==3.10
importlib_resources==6.5.2
inflection==0.5.1
jmespath==1.1.0
jsonschema==4.24.0
jsonschema-specifications==2025.4.1
kombu==5.5.4
Mako==1.3.10
Markdown==3.8
MarkupSafe==3.0.2
minio==7.2.20
msgpack==1.1.1
openpyxl==3.1.5
packaging==25.0
@@ -44,6 +52,8 @@ pillow==11.2.1
prometheus_client==0.22.1
prompt_toolkit==3.0.51
psycopg2-binary==2.9.10
pycparser==3.0
pycryptodome==3.23.0
PyJWT==2.9.0
python-dateutil==2.9.0.post0
python-dotenv==1.1.0
@@ -55,6 +65,7 @@ redis==6.2.0
referencing==0.36.2
requests==2.32.4
rpds-py==0.25.1
s3transfer==0.16.0
six==1.17.0
sniffio==1.3.1
SQLAlchemy==2.0.36