fix/de los tickets T2026-05-027, T2025-09-004 y T2025-09-056

This commit is contained in:
2026-06-15 11:18:58 -06:00
parent 7644446267
commit 23ed52c78a
29 changed files with 2992 additions and 987 deletions

View File

@@ -23,6 +23,7 @@ from core.permissions import (
get_org_context,
require_permission,
user_has_permission,
user_has_role,
is_internal_service_request,
)
from api.customs.models import (
@@ -33,6 +34,7 @@ from api.customs.models import (
Cove,
Importador,
Partida,
EstadoDescarga,
)
from api.customs.serializers import (
PedimentoSerializer,
@@ -2338,9 +2340,19 @@ class PartidaViewSet(viewsets.ModelViewSet):
if not org:
return Partida.objects.none()
qs = Partida.objects.filter(pedimento__organizacion=org)
# Misma precedencia que los mixins de filtrado: superuser y roles
# operativos ven todo lo de su org; is_importador no los degrada.
if (
user.is_superuser or
user_has_role(user, 'admin') or
user_has_role(user, 'developer') or
user_has_role(user, 'Agente Aduanal') or
user_has_role(user, 'user')
):
return qs
if user.is_importador:
qs = qs.filter(pedimento__contribuyente__in=user.rfc.all())
return qs
return qs.filter(pedimento__contribuyente__in=user.rfc.all())
return Partida.objects.none()
def perform_create(self, serializer):
if is_internal_service_request(self.request):
@@ -2456,12 +2468,20 @@ class ViewSetProcesamientoPedimento(viewsets.ModelViewSet, ProcesosPorOrganizaci
org = get_org_context(user)
if not org:
return ProcesamientoPedimento.objects.none()
qs = ProcesamientoPedimento.objects.filter(organizacion=org)
# Misma precedencia que los mixins de filtrado: superuser y roles
# operativos ven todo lo de su org; is_importador no los degrada.
if (
user.is_superuser or
user_has_role(user, 'admin') or
user_has_role(user, 'developer') or
user_has_role(user, 'Agente Aduanal') or
user_has_role(user, 'user')
):
return qs
if user.is_importador:
return ProcesamientoPedimento.objects.filter(
organizacion=org,
pedimento__contribuyente__in=user.rfc.all()
)
return ProcesamientoPedimento.objects.filter(organizacion=org)
return qs.filter(pedimento__contribuyente__in=user.rfc.all())
return ProcesamientoPedimento.objects.none()
def perform_create(self, serializer):
if is_internal_service_request(self.request):
@@ -2485,6 +2505,53 @@ class ViewSetProcesamientoPedimento(viewsets.ModelViewSet, ProcesosPorOrganizaci
my_tags = ['Procesamientos_Pedimentos']
def _crear_documento_error_vu(registro, numero, doc_type_error_id, mensaje, file_prefix='error_vu'):
"""
Crea un Document de error VU (tipos 20/22/24/26) para dejar evidencia en la
pestaña Errores VU cuando se detecta una inconsistencia de descarga.
`registro` debe tener pedimento y organizacion. El nombre del archivo incluye
el número del registro para que el frontend lo asocie (archivo__icontains).
"""
import logging
logger = logging.getLogger('api.customs.views')
doc_type_error = DocumentType.objects.filter(id=doc_type_error_id).first()
if not doc_type_error:
return
error_content = mensaje.encode('utf-8')
tmp_path = None
try:
with tempfile.NamedTemporaryFile(mode='wb', suffix='.txt', delete=False) as f:
f.write(error_content)
tmp_path = f.name
pedimento_app = getattr(registro.pedimento, 'pedimento_app', str(registro.pedimento.pedimento))
file_name = f"{file_prefix}_{numero}.txt"
saved_path = storage_service.save_document_from_path(
file_path=tmp_path,
file_name=file_name,
organizacion_id=registro.organizacion_id,
pedimento_app=pedimento_app
)
if saved_path:
Document.objects.create(
organizacion=registro.organizacion,
pedimento=registro.pedimento,
archivo=saved_path,
document_type=doc_type_error,
extension='TXT',
size=len(error_content),
fuente=None,
)
except Exception as e:
logger.error(f"Error creando documento de error VU para {numero}: {e}")
finally:
if tmp_path and os.path.exists(tmp_path):
os.unlink(tmp_path)
class ViewSetEDocument(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltradaMixin):
"""
ViewSet for EDocument model.
@@ -2492,7 +2559,18 @@ class ViewSetEDocument(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
serializer_class = EDocumentSerializer
pagination_class = CustomPagination
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_fields = ['pedimento', 'numero_edocument', 'organizacion']
filterset_fields = {
'pedimento': ['exact'],
'numero_edocument': ['exact', 'icontains'],
'organizacion': ['exact'],
'clave': ['exact', 'icontains'],
'descripcion': ['icontains'],
'edocument_descargado': ['exact'],
'acuse_descargado': ['exact'],
'edocument_estado': ['exact'],
'acuse_estado': ['exact'],
'created_at': ['gte', 'lte'],
}
search_fields = ['numero_edocument', 'descripcion', 'organizacion']
ordering_fields = ['created_at', 'updated_at', 'numero_edocument']
ordering = ['-created_at']
@@ -2510,6 +2588,7 @@ class ViewSetEDocument(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
'destroy': 'edocuments.delete',
'bulk_delete_edocs_vu': 'edocuments.delete',
'reset_acuse': 'edocuments.edit',
'reset_edocument': 'edocuments.edit',
}
codename = perms.get(self.action, 'edocuments.view')
return [IsAuthenticated(), require_permission(codename)()]
@@ -2539,28 +2618,30 @@ class ViewSetEDocument(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
@action(detail=True, methods=['post'], url_path='reset-acuse')
def reset_acuse(self, request, pk=None):
"""
Detecta inconsistencia cuando acuse_descargado=True pero no existe el documento
de acuse (tipo 4). Crea un registro de error tipo 26 para Errores VU y
restablece acuse_descargado=False para permitir reintentar.
Detecta inconsistencia cuando el acuse está marcado como descargado pero el
documento de acuse (tipo 4) no existe en BD o el archivo falta en storage.
Crea un registro de error tipo 26 para Errores VU y restablece
acuse_estado='pendiente' con contador de intentos en 0 — única vía que
re-habilita el reintento automático (T2026-05-027).
"""
from api.record.models import Document, DocumentType
import logging
logger = logging.getLogger('api.customs.views')
edoc = self.get_object()
if not edoc.acuse_descargado:
if edoc.acuse_estado != EstadoDescarga.DESCARGADO:
return Response(
{"error": "El acuse no está marcado como descargado"},
status=status.HTTP_400_BAD_REQUEST
)
# Verificar si el acuse PDF (tipo 4 = Pedimento Acuse) existe realmente
acuse_disponible = Document.objects.filter(
# Verificar el acuse (tipo 4 = Pedimento Acuse) en BD y físicamente en storage
acuse_docs = Document.objects.filter(
pedimento=edoc.pedimento,
archivo__icontains=edoc.numero_edocument,
document_type_id=4
).exists()
)
acuse_disponible = any(
doc.size and storage_service.file_exists(doc.archivo.name)
for doc in acuse_docs
)
if acuse_disponible:
return Response(
@@ -2568,51 +2649,74 @@ class ViewSetEDocument(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
status=status.HTTP_200_OK
)
# Inconsistencia confirmada: crear documento de error tipo 26 para Errores VU
doc_type_error = DocumentType.objects.filter(id=26).first()
if doc_type_error:
error_content = (
# Inconsistencia confirmada: dejar evidencia en Errores VU (tipo 26)
_crear_documento_error_vu(
registro=edoc,
numero=edoc.numero_edocument,
doc_type_error_id=26,
mensaje=(
f"Inconsistencia detectada: el acuse del EDocument {edoc.numero_edocument} "
f"fue marcado como descargado pero el documento no se encuentra disponible. "
f"El estado fue restablecido para permitir reprocesamiento."
).encode('utf-8')
),
file_prefix='error_acuse',
)
try:
with tempfile.NamedTemporaryFile(
mode='wb', suffix='.txt', delete=False
) as f:
f.write(error_content)
tmp_path = f.name
edoc.acuse_estado = EstadoDescarga.PENDIENTE
edoc.acuse_intentos = 0
edoc.ultimo_error = None
edoc.save()
pedimento_app = getattr(edoc.pedimento, 'pedimento_app', str(edoc.pedimento.pedimento))
file_name = f"error_acuse_{edoc.numero_edocument}.txt"
serializer = self.get_serializer(edoc)
return Response(serializer.data, status=status.HTTP_200_OK)
saved_path = storage_service.save_document_from_path(
file_path=tmp_path,
file_name=file_name,
organizacion_id=edoc.organizacion_id,
pedimento_app=pedimento_app
)
@action(detail=True, methods=['post'], url_path='reset-edocument')
def reset_edocument(self, request, pk=None):
"""
Igual que reset-acuse pero para el documento general del EDocument: si está
marcado como descargado sin documento disponible (BD o storage), crea error
tipo 22 y restablece edocument_estado='pendiente' con contador en 0.
"""
edoc = self.get_object()
if saved_path:
Document.objects.create(
organizacion=edoc.organizacion,
pedimento=edoc.pedimento,
archivo=saved_path,
document_type=doc_type_error,
extension='TXT',
size=len(error_content),
fuente=None,
)
except Exception as e:
logger.error(
f"Error creando documento de error para acuse {edoc.numero_edocument}: {e}"
)
finally:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
if edoc.edocument_estado != EstadoDescarga.DESCARGADO:
return Response(
{"error": "El e-documento no está marcado como descargado"},
status=status.HTTP_400_BAD_REQUEST
)
edoc.acuse_descargado = False
# Documentos generales del EDocument: se excluyen acuse (4), requests (21, 25)
# y errores (22, 26) del catálogo document_type
edoc_docs = Document.objects.filter(
pedimento=edoc.pedimento,
archivo__icontains=edoc.numero_edocument,
).exclude(document_type_id__in=[4, 21, 22, 25, 26])
edoc_disponible = any(
doc.size and storage_service.file_exists(doc.archivo.name)
for doc in edoc_docs
)
if edoc_disponible:
return Response(
{"status": "El e-documento está disponible correctamente", "edocument_disponible": True},
status=status.HTTP_200_OK
)
_crear_documento_error_vu(
registro=edoc,
numero=edoc.numero_edocument,
doc_type_error_id=22,
mensaje=(
f"Inconsistencia detectada: el EDocument {edoc.numero_edocument} fue marcado "
f"como descargado pero el documento no se encuentra disponible. "
f"El estado fue restablecido para permitir reprocesamiento."
),
file_prefix='error_edocument',
)
edoc.edocument_estado = EstadoDescarga.PENDIENTE
edoc.edocument_intentos = 0
edoc.ultimo_error = None
edoc.save()
serializer = self.get_serializer(edoc)
@@ -2625,7 +2729,16 @@ class ViewSetCove(viewsets.ModelViewSet, OrganizacionFiltradaMixin):
serializer_class = CoveSerializer
pagination_class = CustomPagination
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_fields = ['pedimento', 'numero_cove', 'organizacion']
filterset_fields = {
'pedimento': ['exact'],
'numero_cove': ['exact', 'icontains'],
'organizacion': ['exact'],
'cove_descargado': ['exact'],
'acuse_cove_descargado': ['exact'],
'cove_estado': ['exact'],
'acuse_cove_estado': ['exact'],
'created_at': ['gte', 'lte'],
}
search_fields = ['numero_cove', 'descripcion', 'organizacion']
ordering_fields = ['created_at', 'updated_at', 'numero_cove']
ordering = ['-created_at']
@@ -2642,6 +2755,8 @@ class ViewSetCove(viewsets.ModelViewSet, OrganizacionFiltradaMixin):
'partial_update': 'coves.edit',
'destroy': 'coves.delete',
'bulk_delete_coves_vu': 'coves.delete',
'reset_cove': 'coves.edit',
'reset_acuse_cove': 'coves.edit',
}
codename = perms.get(self.action, 'coves.view')
return [IsAuthenticated(), require_permission(codename)()]
@@ -2668,6 +2783,110 @@ class ViewSetCove(viewsets.ModelViewSet, OrganizacionFiltradaMixin):
def perform_destroy(self, instance):
instance.delete()
@action(detail=True, methods=['post'], url_path='reset-cove')
def reset_cove(self, request, pk=None):
"""
Detecta inconsistencia cuando la COVE está marcada como descargada pero el
documento no existe en BD o el archivo falta en storage. Crea error tipo 20
para Errores VU y restablece cove_estado='pendiente' con contador en 0.
"""
cove = self.get_object()
if cove.cove_estado != EstadoDescarga.DESCARGADO:
return Response(
{"error": "La COVE no está marcada como descargada"},
status=status.HTTP_400_BAD_REQUEST
)
# Documentos generales de la COVE: se excluyen acuse (7), requests (19, 23)
# y errores (20, 24) del catálogo document_type
cove_docs = Document.objects.filter(
pedimento=cove.pedimento,
archivo__icontains=cove.numero_cove,
).exclude(document_type_id__in=[7, 19, 20, 23, 24])
cove_disponible = any(
doc.size and storage_service.file_exists(doc.archivo.name)
for doc in cove_docs
)
if cove_disponible:
return Response(
{"status": "La COVE está disponible correctamente", "cove_disponible": True},
status=status.HTTP_200_OK
)
_crear_documento_error_vu(
registro=cove,
numero=cove.numero_cove,
doc_type_error_id=20,
mensaje=(
f"Inconsistencia detectada: la COVE {cove.numero_cove} fue marcada "
f"como descargada pero el documento no se encuentra disponible. "
f"El estado fue restablecido para permitir reprocesamiento."
),
file_prefix='error_cove',
)
cove.cove_estado = EstadoDescarga.PENDIENTE
cove.cove_intentos = 0
cove.ultimo_error = None
cove.save()
serializer = self.get_serializer(cove)
return Response(serializer.data, status=status.HTTP_200_OK)
@action(detail=True, methods=['post'], url_path='reset-acuse-cove')
def reset_acuse_cove(self, request, pk=None):
"""
Detecta inconsistencia cuando el acuse de la COVE (tipo 7) está marcado como
descargado pero no existe en BD o el archivo falta en storage. Crea error
tipo 24 para Errores VU y restablece acuse_cove_estado='pendiente' con
contador en 0.
"""
cove = self.get_object()
if cove.acuse_cove_estado != EstadoDescarga.DESCARGADO:
return Response(
{"error": "El acuse de la COVE no está marcado como descargado"},
status=status.HTTP_400_BAD_REQUEST
)
acuse_docs = Document.objects.filter(
pedimento=cove.pedimento,
archivo__icontains=cove.numero_cove,
document_type_id=7
)
acuse_disponible = any(
doc.size and storage_service.file_exists(doc.archivo.name)
for doc in acuse_docs
)
if acuse_disponible:
return Response(
{"status": "El acuse está disponible correctamente", "acuse_disponible": True},
status=status.HTTP_200_OK
)
_crear_documento_error_vu(
registro=cove,
numero=cove.numero_cove,
doc_type_error_id=24,
mensaje=(
f"Inconsistencia detectada: el acuse de la COVE {cove.numero_cove} "
f"fue marcado como descargado pero el documento no se encuentra disponible. "
f"El estado fue restablecido para permitir reprocesamiento."
),
file_prefix='error_acuse_cove',
)
cove.acuse_cove_estado = EstadoDescarga.PENDIENTE
cove.acuse_cove_intentos = 0
cove.ultimo_error = None
cove.save()
serializer = self.get_serializer(cove)
return Response(serializer.data, status=status.HTTP_200_OK)
class ImportadorViewSet(viewsets.ModelViewSet):
"""
ViewSet for Importador model.