diff --git a/api/cards/views.py b/api/cards/views.py
index 7f8ff74..f086999 100644
--- a/api/cards/views.py
+++ b/api/cards/views.py
@@ -11,6 +11,7 @@ from core.permissions import (
get_org_context,
require_permission,
user_has_permission,
+ user_has_role,
)
from api.organization.models import UsoAlmacenamiento, Organizacion
@@ -136,20 +137,28 @@ class ViewPedimentoServicesUtilInformation(LoggingMixin, APIView, FiltroPorOrgan
)
def get_queryset(self):
- if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'):
+ user = self.request.user
+ if not user.is_authenticated or not hasattr(user, 'organizacion'):
return None
-
- org = get_org_context(self.request.user)
+
+ org = get_org_context(user)
if not org:
return ProcesamientoPedimento.objects.none()
- if self.request.user.is_importador:
- return ProcesamientoPedimento.objects.filter(
- pedimento__organizacion=org,
- pedimento__contribuyente__in=self.request.user.rfc.all(),
- )
-
- return ProcesamientoPedimento.objects.filter(pedimento__organizacion=org)
+ qs = ProcesamientoPedimento.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:
+ return qs.filter(pedimento__contribuyente__in=user.rfc.all())
+ return ProcesamientoPedimento.objects.none()
def get(self, request):
queryset = self.get_queryset()
diff --git a/api/cuser/sso_views.py b/api/cuser/sso_views.py
index 2b5e177..e8daedc 100644
--- a/api/cuser/sso_views.py
+++ b/api/cuser/sso_views.py
@@ -7,13 +7,14 @@ Cuatro endpoints:
POST /api/v1/auth/logout/ — cierra sesión (limpia cookies)
"""
import logging
+import re
from typing import Optional
import requests as http
from django.conf import settings
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
-from rest_framework.permissions import AllowAny, IsAuthenticated
+from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from .hub_auth import (
@@ -28,16 +29,26 @@ logger = logging.getLogger(__name__)
HUB_URL = lambda: getattr(settings, "HUB_URL", "https://workspace.aduanasoft.com").rstrip("/")
+def _slug_from_nombre(nombre: str) -> str:
+ """Deriva un slug válido del nombre de la organización: "TEMEX S.A." → "temex"."""
+ return re.sub(r'[^a-z0-9]+', '-', nombre.lower()).strip('-')[:100]
+
+
def _provision_user_in_hub(username: str, password: str) -> bool:
"""
Crea/sincroniza el usuario en KC vía Hub /auth/provision-user.
- Solo se llama cuando Hub devuelve 401 (usuario no existe en KC).
- Retorna True si la provisión fue exitosa o el usuario ya existía.
+ Solo se llama cuando el usuario no tiene keycloak_user_id (first login).
+
+ Envía new_tenant=True: el Hub crea el tenant (y su licencia por defecto) si
+ aún no existe, usando el slug de la organización de EFC.
+ Flujo:
+ 1. Obtener org del usuario → derivar/usar hub_tenant_slug
+ 2. Provisionar al usuario; el Hub resuelve/crea el tenant y le asigna acceso
"""
from django.db.models import Q
from api.cuser.models import CustomUser
- user = CustomUser.objects.filter(
+ user = CustomUser.objects.select_related('organizacion').filter(
Q(username=username) | Q(email=username),
is_active=True,
).first()
@@ -45,20 +56,42 @@ def _provision_user_in_hub(username: str, password: str) -> bool:
if not user:
return False
- tenant_slug = getattr(settings, "HUB_TENANT_SLUG", "efc")
- provision_secret = getattr(settings, "HUB_PROVISION_SECRET", "")
+ org = user.organizacion
+ if not org:
+ logger.warning("[provision] Usuario %s sin organización asignada — omitiendo provisión", username)
+ return False
+
+ # Determinar slug del tenant: usar el guardado o derivarlo del nombre
+ tenant_slug = org.hub_tenant_slug
+ if not tenant_slug:
+ tenant_slug = _slug_from_nombre(org.nombre)
+ # Persistir para no recalcular en futuros logins
+ type(org).objects.filter(pk=org.pk).update(hub_tenant_slug=tenant_slug)
+ logger.info("[provision] Slug derivado para org '%s' → '%s'", org.nombre, tenant_slug)
+
+ provision_secret = getattr(settings, "HUB_PROVISION_SECRET", "")
+
+ # Rol del usuario en el tenant: si tiene el rol admin de su organización lo
+ # provisionamos como admin del tenant en Hub; de lo contrario, como operador.
+ from api.rbac.models import UserRole
+ is_org_admin = UserRole.objects.filter(user=user, role__is_admin_role=True).exists()
+ role = "admin" if is_org_admin else "operador"
try:
r = http.post(
f"{HUB_URL()}/api/v1/auth/provision-user",
+ # new_tenant=True → el Hub crea el tenant y su licencia si no existe.
json={
- "username": user.username,
- "email": user.email or f"{user.username}@efc.local",
- "password": password,
- "first_name": user.first_name or "",
- "last_name": user.last_name or "",
- "tenant_slug": tenant_slug,
- "role": "operador",
+ "username": user.username,
+ "email": user.email or f"{user.username}@efc.local",
+ "password": password,
+ "first_name": user.first_name or "",
+ "last_name": user.last_name or "",
+ "tenant_slug": tenant_slug,
+ "tenant_name": org.nombre,
+ "product_slug": "efc",
+ "role": role,
+ "new_tenant": True,
},
headers={"X-Provision-Secret": provision_secret},
timeout=15,
@@ -82,7 +115,8 @@ def _provision_user_in_hub(username: str, password: str) -> bool:
if kc_id:
CustomUser.objects.filter(pk=user.pk).update(keycloak_user_id=kc_id)
- logger.info("[provision] Usuario %s provisionado — KC id: %s", user.username, kc_id)
+ logger.info("[provision] Usuario %s → tenant '%s' — KC id: %s",
+ user.username, tenant_slug, kc_id)
else:
logger.warning("[provision] No se pudo extraer KC UUID para %s", user.username)
return True
@@ -96,6 +130,25 @@ def _provision_user_in_hub(username: str, password: str) -> bool:
return False
+def _verify_password_against_hub(username: str, password: str) -> bool:
+ """
+ Verifica credenciales contra el Hub (KC vía /auth/login).
+ Se usa cuando el login local falla para usuarios traídos del Hub vía SSO,
+ que no tienen contraseña local usable. Retorna True solo si el Hub responde 200.
+ """
+ try:
+ r = http.post(
+ f"{HUB_URL()}/api/v1/auth/login",
+ json={"username": username, "password": password},
+ timeout=15,
+ )
+ except http.exceptions.RequestException as exc:
+ logger.error("[login] Error de red verificando credenciales en Hub para %s: %s", username, exc)
+ return False
+ # 200 = credenciales válidas (tokens o selector de tenant). 401 = inválidas.
+ return r.status_code == 200
+
+
def _extract_token(request) -> Optional[str]:
auth = request.META.get("HTTP_AUTHORIZATION", "")
if auth.lower().startswith("bearer "):
@@ -105,6 +158,106 @@ def _extract_token(request) -> Optional[str]:
return request.COOKIES.get("access_token")
+# ---------------------------------------------------------------------------
+# Helpers SSO: auto-provisión Hub → EFC
+# ---------------------------------------------------------------------------
+
+def _ensure_efc_organization(tenant_slug: str, tenant_name: str = None):
+ """
+ Devuelve (org, created). Si no existe, la crea con datos mínimos.
+ El nombre viene del Hub (tenant_name); si no llega, se deriva del slug.
+ El admin completa RFC, etc. desde el panel de Django.
+ """
+ from api.organization.models import Organizacion
+ from api.licence.models import Licencia
+
+ org = Organizacion.objects.filter(hub_tenant_slug=tenant_slug).first()
+ if org:
+ return org, False
+
+ licencia, _ = Licencia.objects.get_or_create(
+ nombre='Hub SSO Default',
+ defaults={'almacenamiento': 0},
+ )
+ org = Organizacion.objects.create(
+ hub_tenant_slug=tenant_slug,
+ nombre=(tenant_name or '').strip() or tenant_slug.upper().replace('-', ' '),
+ licencia=licencia,
+ rfc='XAXX010101000',
+ titular='',
+ email='',
+ telefono='',
+ estado='',
+ ciudad='',
+ is_active=True,
+ )
+ logger.info("[sso] Organizacion creada para tenant Hub '%s'", tenant_slug)
+ return org, True
+
+
+def _ensure_efc_user(hub_data: dict, org):
+ """
+ Devuelve (user, created). Si no existe, lo crea vinculado a la organización.
+ Si ya existe pero le falta el KC id o la org, los completa.
+ """
+ from django.db.models import Q
+ from api.cuser.models import CustomUser
+
+ kc_id = hub_data.get('user_id')
+ email = hub_data.get('email', '')
+ username = (hub_data.get('preferred_username') or email or '').strip()
+
+ user = None
+ if kc_id:
+ user = CustomUser.objects.filter(keycloak_user_id=kc_id).first()
+ if not user and (email or username):
+ user = CustomUser.objects.filter(
+ Q(email=email) | Q(username=username)
+ ).first()
+
+ if user:
+ updates = {}
+ if kc_id and not user.keycloak_user_id:
+ updates['keycloak_user_id'] = kc_id
+ if org and not user.organizacion_id:
+ updates['organizacion'] = org
+ if updates:
+ CustomUser.objects.filter(pk=user.pk).update(**updates)
+ return user, False
+
+ # Usuario nuevo — contraseña inutilizable (solo SSO)
+ name = (hub_data.get('name') or '').strip()
+ parts = name.split(' ', 1) if name else []
+ first = parts[0] if parts else ''
+ last = parts[1] if len(parts) > 1 else ''
+
+ user = CustomUser.objects.create_user(
+ username=username,
+ email=email,
+ first_name=first,
+ last_name=last,
+ password=None,
+ is_active=True,
+ keycloak_user_id=kc_id,
+ organizacion=org,
+ )
+ logger.info("[sso] Usuario '%s' creado desde Hub SSO → org '%s'",
+ username, org.nombre if org else 'sin org')
+ return user, True
+
+
+def _assign_admin_role(user, org):
+ """Asigna el rol admin de la org al usuario. No-op si ya lo tiene."""
+ from api.rbac.models import OrganizationRole, UserRole
+ try:
+ admin_role = OrganizationRole.objects.get(organizacion=org, nombre='admin')
+ _, assigned = UserRole.objects.get_or_create(user=user, role=admin_role)
+ if assigned:
+ logger.info("[sso] Rol admin asignado a '%s' en org '%s'", user.username, org.nombre)
+ except OrganizationRole.DoesNotExist:
+ logger.warning("[sso] Rol admin no encontrado para org '%s' — ¿signals ejecutados?", org.nombre)
+
+
# ---------------------------------------------------------------------------
# POST /api/v1/auth/login/
# ---------------------------------------------------------------------------
@@ -116,7 +269,6 @@ def login_view(request):
Login directo con Django auth + SimpleJWT.
No llama al Hub en cada login — solo la primera vez si el usuario
no tiene keycloak_user_id (provisión one-shot transparente).
- Soporta ambos modos: login directo aquí O login vía Hub SSO.
"""
from django.contrib.auth import authenticate as django_auth
from django.db.models import Q
@@ -130,10 +282,8 @@ def login_view(request):
return Response({"detail": "username y password son requeridos"},
status=status.HTTP_400_BAD_REQUEST)
- # Autenticar directamente con Django (rápido, sin tocar Hub)
user = django_auth(request, username=username, password=password)
- # Fallback: buscar por email si username no matcheó
if not user:
user_by_email = CustomUser.objects.filter(
Q(email=username), is_active=True
@@ -141,10 +291,23 @@ def login_view(request):
if user_by_email:
user = django_auth(request, username=user_by_email.username, password=password)
+ # Fallback Hub: los usuarios traídos del Hub vía SSO se crean sin contraseña local
+ # usable (set_unusable_password), así que django_auth falla. Si el usuario está
+ # vinculado al Hub (keycloak_user_id), verificamos la contraseña contra el Hub y, si
+ # es válida, la "localizamos" en EFC para que los próximos logins sean directos.
+ if not user:
+ hub_user = CustomUser.objects.filter(
+ Q(username=username) | Q(email=username), is_active=True
+ ).first()
+ if hub_user and hub_user.keycloak_user_id and _verify_password_against_hub(hub_user.username, password):
+ hub_user.set_password(password)
+ hub_user.save(update_fields=["password"])
+ user = hub_user
+ logger.info("[login] Contraseña localizada en EFC para usuario Hub '%s'", hub_user.username)
+
if not user or not user.is_active:
return Response({"detail": "Credenciales inválidas"}, status=401)
- # ── Provisión one-shot (solo primera vez, solo si no tiene KC id) ──────────
first_login = not bool(user.keycloak_user_id)
if first_login:
import threading
@@ -158,7 +321,6 @@ def login_view(request):
threading.Thread(target=_provision_async, daemon=True).start()
logger.info("[login] Provisión iniciada en background para %s", user.username)
- # ── Emitir tokens SimpleJWT (igual que siempre) ────────────────────────────
refresh = RefreshToken.for_user(user)
return Response({
@@ -183,7 +345,8 @@ def login_view(request):
def sso_exchange_view(request):
"""
Canjea relay token del Hub por sesión local.
- Usado en: flujo SSO entre productos y login con Microsoft.
+ Además de emitir tokens, auto-provisiona la organización y el usuario
+ en la BD de EFC si aún no existen (flujo Hub → EFC).
"""
relay_token = request.data.get("relay_token", "").strip()
if not relay_token:
@@ -205,7 +368,18 @@ def sso_exchange_view(request):
logger.error("Hub %s en SSO exchange: %s", r.status_code, r.text[:200])
return Response({"detail": "No se pudo completar el inicio de sesión"}, status=401)
- data = r.json()
+ data = r.json()
+ tenant_slug = data.get("tenant_slug")
+
+ try:
+ org, org_created = _ensure_efc_organization(tenant_slug, data.get("tenant_name")) if tenant_slug else (None, False)
+ user, user_created = _ensure_efc_user(data, org)
+ # Primer usuario de una org nueva → admin automático
+ if org_created and user_created and org and user:
+ _assign_admin_role(user, org)
+ except Exception as exc:
+ logger.error("[sso] Error en auto-provisión EFC para tenant '%s': %s", tenant_slug, exc)
+
local_tokens = create_local_tokens({
"id": data.get("user_id"),
"username": data.get("preferred_username") or data.get("email", ""),
@@ -215,7 +389,7 @@ def sso_exchange_view(request):
"last_name": "",
"is_hub_admin": data.get("is_hub_admin", False),
"tenant_id": data.get("tenant_id"),
- "tenant_slug": data.get("tenant_slug"),
+ "tenant_slug": tenant_slug,
})
response = Response({
@@ -224,14 +398,14 @@ def sso_exchange_view(request):
"name": data.get("name"),
"username": data.get("preferred_username"),
"tenant_id": data.get("tenant_id"),
- "tenant_slug": data.get("tenant_slug"),
+ "tenant_slug": tenant_slug,
"is_hub_admin": data.get("is_hub_admin", False),
"avatar_url": data.get("avatar_url"),
"access_token": local_tokens["access_token"],
"refresh_token": local_tokens["refresh_token"],
})
set_session_cookies(response, local_tokens)
- logger.info("SSO exchange OK — usuario %s tenant %s", data.get("user_id"), data.get("tenant_slug"))
+ logger.info("SSO exchange OK — usuario %s tenant %s", data.get("user_id"), tenant_slug)
return response
@@ -271,7 +445,6 @@ def me_view(request):
"organizacion_id": str(user.organizacion_id) if user.organizacion_id else None,
})
- # Usuario Hub sin cuenta Django (pre-migración)
return Response({
"id": hub_data.get("sub"),
"username": hub_data.get("preferred_username") or hub_data.get("email", ""),
@@ -327,7 +500,6 @@ def refresh_view(request):
except pyjwt.InvalidTokenError:
return Response({"detail": "Refresh token inválido"}, status=401)
- # Emitir nuevos tokens locales con los mismos claims
new_tokens = create_local_tokens({
"id": payload.get("sub"),
"username": payload.get("preferred_username", ""),
@@ -346,7 +518,7 @@ def refresh_view(request):
# ---------------------------------------------------------------------------
-# POST /api/v1/auth/session/refresh/ ← NUEVO (cookie-based)
+# POST /api/v1/auth/session/refresh/
# ---------------------------------------------------------------------------
@api_view(["POST"])
@@ -356,9 +528,6 @@ def session_refresh_view(request):
Renueva la sesión usando SOLO la cookie HTTP-only refresh_token.
No requiere body. Diseñado para el flujo SSO donde el refresh_token
no vive en localStorage sino en cookie.
-
- Devuelve { access_token, access } — ambas claves para compatibilidad
- con distintas versiones del frontend.
"""
refresh_token = request.COOKIES.get("refresh_token")
if not refresh_token:
@@ -389,7 +558,7 @@ def session_refresh_view(request):
access = new_tokens["access_token"]
response = Response({
"access_token": access,
- "access": access, # compatibilidad con fetchWithAuth legacy
+ "access": access,
})
set_session_cookies(response, new_tokens)
return response
diff --git a/api/customs/management/commands/fix_partidas_error.py b/api/customs/management/commands/fix_partidas_error.py
index a66b01b..e04a647 100644
--- a/api/customs/management/commands/fix_partidas_error.py
+++ b/api/customs/management/commands/fix_partidas_error.py
@@ -1,31 +1,52 @@
"""
-Diagnóstico y corrección de partidas con descargado=True cuyos documentos
-de respuesta VUCEM contienen true.
+Diagnóstico y corrección de partidas con descargado=True que NO tienen un XML
+de respuesta de partida válido.
+
+Una partida cuenta como realmente descargada solo si alguno de sus documentos
+contiene el nodo sin true.
+
+Clasificación por contenido de cada documento candidato (excluye types 17/18,
+que ya están identificados como REQUEST/ERROR):
+ - valida : consultarPartidaRespuesta sin tieneError=true
+ - error : tieneError=true → renombra a _ERROR, type 18
+ - request : consultarPartidaPeticion → renombra a _REQUEST, type 17
+ (eco de la petición guardado como si fuera respuesta)
+ - desconocido : contenido no identificable → solo reporte
+ - ausente : registro en BD cuyo archivo no existe en storage
+ - no_verificable : storage inaccesible (excepción al consultar/leer)
+
+Veredicto por partida con descargado=True:
+ - ≥1 valida → conserva descargado=True
+ - 0 validas y ≥1 no_verificable → sin cambios (storage inaccesible)
+ - 0 validas, ≥1 ausente y NINGÚN archivo del pedimento existe en storage
+ → sin cambios (canario: probablemente se
+ está corriendo contra un storage que no
+ es el de esta BD, p. ej. dev)
+ - en cualquier otro caso → descargado=False (incluye partidas que
+ solo tienen el REQUEST, ningún doc, o
+ registros fantasma con el storage real)
+
+Canario de storage: si al menos un archivo vu_PT_ del pedimento (REQUEST,
+ERROR o respuesta) sí existe en storage, el storage es el correcto y los
+documentos ausentes son registros fantasma reales (BD sin archivo).
Convenciones de nomenclatura del microservicio:
- REQUEST (type 17): vu_PT_{pedimento_app}_{partida}_REQUEST.xml
- ERROR (type 18): vu_PT_{pedimento_app}_{partida}_ERROR.xml
- Éxito (type 1): vu_PT_{pedimento_app}_{partida}.xml
-
-Acciones por cada documento con error VUCEM encontrado:
- - document_type_id: actual → 18 (PT ERROR)
- - archivo: renombrado a vu_PT_{pedimento_app}_{partida}_ERROR.xml
- - Partida.descargado: True → False
-
-Criterio de pedimento malformado (cualquiera de):
- - aduana: nulo/vacío o len < 3
- - numero_operacion: nulo o vacío
- - patente: nulo/vacío o len < 4
- - pedimento (campo): nulo/vacío o len < 7
+ (el storage puede agregar sufijos de unicidad: vu_PT_{...}_{partida}_Ab12xQ.xml)
+ - Legacy : vu_PT_..._{partida}.xml (número de partida al final)
Uso:
python manage.py fix_partidas_error --pedimento --dry-run
python manage.py fix_partidas_error --organizacion --dry-run
python manage.py fix_partidas_error --organizacion
+ python manage.py fix_partidas_error --solo-malformados --dry-run
python manage.py fix_partidas_error --dry-run # todas las orgs
"""
import io
import posixpath
+import re
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
@@ -39,9 +60,23 @@ from api.utils.minio_client import minio_client
_PT_REQUEST = 17
_PT_ERROR = 18
+# Clasificaciones por contenido del XML
+_VALIDA = "valida"
+_ERROR_VU = "error"
+_REQUEST_ECO = "request"
+_DESCONOCIDO = "desconocido"
+_AUSENTE = "ausente"
+_NO_VERIFICABLE = "no_verificable"
+
+# clase → (sufijo de archivo, document_type destino)
+_RECLASIFICACION = {
+ _ERROR_VU: ("ERROR", _PT_ERROR),
+ _REQUEST_ECO: ("REQUEST", _PT_REQUEST),
+}
+
class Command(BaseCommand):
- help = "Corrección de partidas descargado=True con respuestas de error VUCEM."
+ help = "Corrige partidas descargado=True sin XML de respuesta de partida válido."
def add_arguments(self, parser):
parser.add_argument(
@@ -61,10 +96,14 @@ class Command(BaseCommand):
"--fecha-hasta", metavar="YYYY-MM-DD",
help="Procesar pedimentos con fecha_pago <= esta fecha.",
)
+ parser.add_argument(
+ "--solo-malformados", action="store_true",
+ help="Limitar a pedimentos con aduana/patente/pedimento/numero_operacion inválidos (comportamiento anterior).",
+ )
# Control de lote
parser.add_argument(
"--offset", type=int, default=0,
- help="Saltar los primeros N pedimentos malformados (default: 0).",
+ help="Saltar los primeros N pedimentos (default: 0).",
)
parser.add_argument(
"--limit", type=int, default=0,
@@ -97,7 +136,12 @@ class Command(BaseCommand):
self._handle_single(ped_id, dry_run)
return
- ped_qs = self._malformed_qs()
+ # Universo: pedimentos con al menos una partida descargado=True
+ ped_ids = Partida.objects.filter(descargado=True).values_list(
+ "pedimento_id", flat=True
+ ).distinct()
+ base_qs = self._malformed_qs() if options["solo_malformados"] else Pedimento.objects.all()
+ ped_qs = base_qs.filter(id__in=ped_ids)
if org_id:
ped_qs = ped_qs.filter(organizacion_id=org_id)
@@ -115,29 +159,32 @@ class Command(BaseCommand):
if limit:
ped_qs = ped_qs[:limit]
- total = ped_qs.count() if not (offset or limit) else min(
+ total = total_sin_filtro if not (offset or limit) else min(
limit or total_sin_filtro, max(0, total_sin_filtro - offset)
)
self.stdout.write(
- f"Pedimentos malformados (total): {total_sin_filtro}\n"
- f"Procesando este lote : {total}"
+ f"Pedimentos con partidas descargadas (total): {total_sin_filtro}\n"
+ f"Procesando este lote : {total}"
+ (f" [offset={offset}]" if offset else "")
+ (f" [limit={limit}]" if limit else "")
+ + (f" [solo malformados]" if options["solo_malformados"] else "")
+ "\n"
)
if total == 0:
- self.stdout.write(self.style.SUCCESS("Nada que corregir en este lote."))
+ self.stdout.write(self.style.SUCCESS("Nada que revisar en este lote."))
return
- total_partidas = total_docs = 0
+ stats = self._stats_vacios()
+ n_peds = 0
for ped in ped_qs:
- p, d = self._process_pedimento(ped, dry_run)
- total_partidas += p
- total_docs += d
+ parciales = self._process_pedimento(ped, dry_run)
+ n_peds += 1
+ for k in stats:
+ stats[k] += parciales[k]
- self._print_summary(total, total_partidas, total_docs, dry_run)
+ self._print_summary(n_peds, stats, dry_run)
# ------------------------------------------------------------------ #
# Flujo --pedimento
@@ -149,11 +196,10 @@ class Command(BaseCommand):
except Pedimento.DoesNotExist:
raise CommandError(f"Pedimento {ped_id!r} no encontrado.")
- checks = self._field_checks(ped)
- self._print_ped_diagnosis(ped, checks)
- if not any(checks.values()):
- return
- self._process_pedimento(ped, dry_run)
+ # Diagnóstico de campos: informativo, ya no excluye pedimentos válidos
+ self._print_ped_diagnosis(ped, self._field_checks(ped))
+ stats = self._process_pedimento(ped, dry_run)
+ self._print_summary(1, stats, dry_run)
# ------------------------------------------------------------------ #
# Queryset de pedimentos malformados
@@ -199,139 +245,247 @@ class Command(BaseCommand):
self.stdout.write("")
# ------------------------------------------------------------------ #
- # Procesamiento de un pedimento malformado
+ # Procesamiento de un pedimento
# ------------------------------------------------------------------ #
+ def _stats_vacios(self):
+ return {
+ "partidas": 0, # partidas descargado=True revisadas
+ "corregidas": 0, # partidas marcadas descargado=False
+ "bloqueadas": 0, # partidas sin cambios (storage inaccesible/equivocado)
+ "docs_error": 0, # docs renombrados a _ERROR (type 18)
+ "docs_request": 0, # docs reclasificados a _REQUEST (type 17)
+ "desconocidos": 0, # docs con contenido no identificable
+ "fantasmas": 0, # registros en BD sin archivo en storage (no se borran)
+ }
+
def _process_pedimento(self, ped, dry_run):
+ es_malformado = any(self._field_checks(ped).values())
self.stdout.write(
f"Pedimento: {ped.pedimento_app} | "
f"aduana={ped.aduana!r} patente={ped.patente!r} num_op={ped.numero_operacion!r}"
+ + (" [MALFORMADO]" if es_malformado else "")
)
+ stats = self._stats_vacios()
+
partidas = Partida.objects.filter(pedimento=ped, descargado=True)
n_partidas = partidas.count()
-
if n_partidas == 0:
self.stdout.write(" → Sin partidas con descargado=True\n")
- return 0, 0
+ return stats
self.stdout.write(f" Partidas con descargado=True: {n_partidas}")
- total_docs_error = 0
+
+ # Una sola consulta por pedimento; la asignación por partida es en memoria
+ docs_pedimento = list(
+ Document.objects.filter(pedimento=ped, archivo__icontains="vu_PT_")
+ )
+
+ # Canario perezoso: ¿existe en storage al menos un archivo del pedimento?
+ # Distingue "registro fantasma con storage real" de "storage equivocado".
+ canario = {"valor": None}
+
+ def storage_es_correcto():
+ if canario["valor"] is None:
+ canario["valor"] = self._storage_tiene_archivos(docs_pedimento)
+ return canario["valor"]
for partida in partidas:
- # Documentos de respuesta: excluir REQUEST (17) y los ya marcados ERROR (18)
- patron = f"vu_PT_{ped.pedimento_app}_{partida.numero_partida}_"
- candidatos = list(
- Document.objects.filter(
- pedimento=ped,
- archivo__icontains=patron,
- ).exclude(document_type_id__in=[_PT_REQUEST, _PT_ERROR])
- )
-
- self.stdout.write(
- f"\n Partida {partida.numero_partida}: {len(candidatos)} doc(s) candidatos a revisar"
- )
-
- docs_con_error = []
- for doc in candidatos:
- # estado: "error" | "ok" | "no_verificable"
- estado, motivo = self._check_vucem_error(doc)
- if estado == "error":
- icono = self.style.ERROR("✗ ERROR VUCEM")
- elif estado == "ok":
- icono = self.style.SUCCESS("✓ ok")
- else:
- icono = self.style.WARNING("⚠ sin archivo en storage")
-
- self.stdout.write(f" [{icono}] type={doc.document_type_id} | {doc.archivo.name}")
-
- if estado == "error":
- self.stdout.write(f" motivo : {motivo}")
- new_name = self._build_error_filename(
- doc.archivo.name, ped.pedimento_app, partida.numero_partida, len(docs_con_error)
- )
- self.stdout.write(f" → {new_name}")
- docs_con_error.append(doc)
- elif estado == "no_verificable":
- self.stdout.write(f" {motivo} — ejecuta en producción para verificar")
-
- total_docs_error += len(docs_con_error)
-
- if not dry_run and docs_con_error:
- self._apply_fix(partida, docs_con_error, ped.pedimento_app)
+ self._process_partida(ped, partida, docs_pedimento, storage_es_correcto, dry_run, stats)
self.stdout.write("")
- return n_partidas, total_docs_error
+ return stats
+
+ def _storage_tiene_archivos(self, docs):
+ """True si al menos un archivo vu_PT_ del pedimento existe en storage."""
+ for doc in docs:
+ try:
+ if minio_client.file_exists(doc.archivo.name):
+ return True
+ except Exception:
+ return False # storage inaccesible: modo conservador
+ return False
# ------------------------------------------------------------------ #
- # Detección de error VUCEM en el XML
+ # Procesamiento de una partida
# ------------------------------------------------------------------ #
- def _check_vucem_error(self, doc):
+ def _process_partida(self, ped, partida, docs_pedimento, storage_es_correcto, dry_run, stats):
+ stats["partidas"] += 1
+ docs = self._docs_de_partida(docs_pedimento, ped.pedimento_app, partida.numero_partida)
+ candidatos = [d for d in docs if d.document_type_id not in (_PT_REQUEST, _PT_ERROR)]
+ n_requests = sum(1 for d in docs if d.document_type_id == _PT_REQUEST)
+ n_errores = sum(1 for d in docs if d.document_type_id == _PT_ERROR)
+
+ self.stdout.write(
+ f"\n Partida {partida.numero_partida}: {len(candidatos)} doc(s) de respuesta a revisar"
+ f" (REQUEST: {n_requests}, ERROR previos: {n_errores})"
+ )
+
+ clasificados = []
+ for doc in candidatos:
+ clase, motivo = self._classify_document(doc)
+ iconos = {
+ _VALIDA: self.style.SUCCESS("✓ partida válida"),
+ _ERROR_VU: self.style.ERROR("✗ ERROR VUCEM"),
+ _REQUEST_ECO: self.style.WARNING("↺ es REQUEST, no respuesta"),
+ _DESCONOCIDO: self.style.WARNING("? contenido desconocido"),
+ _AUSENTE: self.style.WARNING("✗ registro sin archivo en storage"),
+ _NO_VERIFICABLE: self.style.WARNING("⚠ storage inaccesible"),
+ }
+ self.stdout.write(f" [{iconos[clase]}] type={doc.document_type_id} | {doc.archivo.name}")
+ if motivo:
+ self.stdout.write(f" motivo: {motivo}")
+ clasificados.append((doc, clase))
+
+ validas = [d for d, c in clasificados if c == _VALIDA]
+ no_verificables = [d for d, c in clasificados if c == _NO_VERIFICABLE]
+ ausentes = [d for d, c in clasificados if c == _AUSENTE]
+ corregibles = [(d, c) for d, c in clasificados if c in _RECLASIFICACION]
+ stats["desconocidos"] += sum(1 for _, c in clasificados if c == _DESCONOCIDO)
+
+ # Veredicto: solo una consultarPartidaRespuesta sin error mantiene la
+ # partida como descargada. Storage inaccesible bloquea el cambio; un
+ # archivo ausente solo bloquea cuando NINGÚN archivo del pedimento
+ # existe en storage (canario: posible storage equivocado, p. ej. dev).
+ if validas:
+ marcar_no_descargada = False
+ veredicto = self.style.SUCCESS("OK: tiene respuesta de partida válida")
+ elif no_verificables:
+ marcar_no_descargada = False
+ stats["bloqueadas"] += 1
+ veredicto = self.style.WARNING(
+ "SIN CAMBIOS: storage inaccesible — ejecutar donde el storage sea accesible"
+ )
+ elif ausentes and not storage_es_correcto():
+ marcar_no_descargada = False
+ stats["bloqueadas"] += 1
+ veredicto = self.style.WARNING(
+ "SIN CAMBIOS: ningún archivo del pedimento existe en storage — "
+ "¿se está corriendo contra el storage correcto?"
+ )
+ else:
+ marcar_no_descargada = True
+ stats["corregidas"] += 1
+ stats["fantasmas"] += len(ausentes)
+ veredicto = self.style.ERROR("descargado → False (sin XML de partida válido)")
+ self.stdout.write(f" Veredicto: {veredicto}")
+
+ for _, clase in corregibles:
+ clave = "docs_error" if clase == _ERROR_VU else "docs_request"
+ stats[clave] += 1
+
+ if not dry_run and (corregibles or marcar_no_descargada):
+ self._apply_fix(partida, corregibles, marcar_no_descargada, ped.pedimento_app)
+
+ # ------------------------------------------------------------------ #
+ # Asignación de documentos a una partida por nombre de archivo
+ # ------------------------------------------------------------------ #
+
+ def _docs_de_partida(self, docs, pedimento_app, numero_partida):
"""
- Lee el XML desde MinIO y verifica si VUCEM devolvió un error.
- Retorna ("error" | "ok" | "no_verificable", motivo: str | None).
+ Naming actual : vu_PT_{pedimento_app}_{numero} seguido de "_" o "."
+ (cubre éxito canónico, sufijos de unicidad del storage,
+ REQUEST y ERROR; "_" evita confundir partida 1 con 11)
+ Naming legacy : vu_PT_..._{numero}.xml (número de partida al final)
"""
+ prefijo = f"vu_pt_{pedimento_app}_{numero_partida}".lower()
+ legacy_re = re.compile(
+ rf"^vu_pt_.+_{re.escape(str(numero_partida))}\.xml$", re.IGNORECASE
+ )
+ asignados = {}
+ for doc in docs:
+ base = posixpath.basename(doc.archivo.name or "").lower()
+ es_actual = (
+ base.startswith(prefijo)
+ and len(base) > len(prefijo)
+ and base[len(prefijo)] in "_."
+ )
+ if es_actual or legacy_re.match(base):
+ asignados[doc.id] = doc
+ return list(asignados.values())
+
+ # ------------------------------------------------------------------ #
+ # Clasificación del contenido XML
+ # ------------------------------------------------------------------ #
+
+ def _classify_document(self, doc):
+ """
+ Lee el XML desde MinIO y clasifica su contenido.
+ Retorna (clase, motivo: str | None).
+ """
+ name = doc.archivo.name
try:
- name = doc.archivo.name
if not minio_client.file_exists(name):
- return "no_verificable", "archivo no encontrado en storage"
+ return _AUSENTE, "archivo no encontrado en storage"
response = minio_client._client.get_object(minio_client._bucket_name, name)
try:
content = response.read()
finally:
response.close()
response.release_conn()
- text = content.decode("utf-8", errors="replace")
- if "tieneError>true<" in text:
- return "error", "tieneError=true detectado en XML"
- return "ok", None
+ text = content.decode("utf-8", errors="replace").lower()
except Exception as e:
- return "no_verificable", f"excepción al leer archivo: {e}"
+ return _NO_VERIFICABLE, f"excepción al leer archivo: {e}"
- # ------------------------------------------------------------------ #
- # Construcción del nombre de archivo de error
- # ------------------------------------------------------------------ #
-
- def _build_error_filename(self, old_name, pedimento_app, numero_partida, index=0):
- """
- Retorna la ruta con nomenclatura de error:
- index=0 → {dir}/vu_PT_{pedimento_app}_{numero_partida}_ERROR.xml
- index>0 → {dir}/vu_PT_{pedimento_app}_{numero_partida}_ERROR_{index}.xml
- El índice evita colisión cuando una partida tiene más de un doc con error.
- """
- dir_part = posixpath.dirname(old_name)
- suffix = f"_{index}" if index > 0 else ""
- new_filename = f"vu_PT_{pedimento_app}_{numero_partida}_ERROR{suffix}.xml"
- return posixpath.join(dir_part, new_filename)
+ if "tieneerror>true<" in text:
+ return _ERROR_VU, "tieneError=true detectado en XML"
+ if "consultarpartidarespuesta" in text:
+ return _VALIDA, None
+ if "consultarpartidapeticion" in text:
+ return _REQUEST_ECO, "es la petición SOAP, no la respuesta"
+ return _DESCONOCIDO, "sin consultarPartidaRespuesta, sin consultarPartidaPeticion y sin tieneError"
# ------------------------------------------------------------------ #
# Aplicación de correcciones
# ------------------------------------------------------------------ #
@transaction.atomic
- def _apply_fix(self, partida, docs, pedimento_app):
+ def _apply_fix(self, partida, corregibles, marcar_no_descargada, pedimento_app):
"""
- Renombra archivos en storage y actualiza BD dentro de una transacción.
- Nota: si la transacción revierte, los cambios en storage NO se deshacen.
+ Renombra/reclasifica documentos y actualiza la partida en una transacción.
+ Nota: si la transacción revierte, los cambios en storage NO se deshacen;
+ re-ejecutar el comando converge (ver _rename_in_storage).
"""
- for idx, doc in enumerate(docs):
- new_name = self._build_error_filename(
- doc.archivo.name, pedimento_app, partida.numero_partida, idx
- )
+ for doc, clase in corregibles:
+ suffix, doc_type = _RECLASIFICACION[clase]
+ new_name = self._pick_target_name(doc, pedimento_app, partida.numero_partida, suffix)
final_name = self._rename_in_storage(doc.archivo.name, new_name)
doc.archivo = final_name
- doc.document_type_id = _PT_ERROR
+ doc.document_type_id = doc_type
doc.vu = True
doc.save(update_fields=["archivo", "document_type_id", "vu"])
self.stdout.write(self.style.SUCCESS(
- f" ✓ Doc {doc.id}: type=18 | {final_name}"
+ f" ✓ Doc {doc.id}: type={doc_type} | {final_name}"
))
- partida.descargado = False
- partida.save(update_fields=["descargado"])
- self.stdout.write(self.style.SUCCESS(
- f" ✓ Partida {partida.numero_partida}: descargado=False"
- ))
+ if marcar_no_descargada:
+ partida.descargado = False
+ partida.save(update_fields=["descargado"])
+ self.stdout.write(self.style.SUCCESS(
+ f" ✓ Partida {partida.numero_partida}: descargado=False"
+ ))
+
+ def _pick_target_name(self, doc, pedimento_app, numero_partida, suffix):
+ """
+ Primer nombre libre con nomenclatura
+ {dir}/vu_PT_{pedimento_app}_{numero_partida}_{SUFFIX}[_{n}].xml
+ verificado contra BD (excluyendo el propio doc) para que dos Documents
+ nunca terminen apuntando al mismo archivo (p. ej. contra el REQUEST
+ real type 17 que ya usa el nombre sin índice).
+ """
+ dir_part = posixpath.dirname(doc.archivo.name)
+ index = 0
+ while True:
+ tail = f"_{index}" if index else ""
+ candidate = posixpath.join(
+ dir_part, f"vu_PT_{pedimento_app}_{numero_partida}_{suffix}{tail}.xml"
+ )
+ if candidate == doc.archivo.name:
+ return candidate
+ if not Document.objects.filter(archivo=candidate).exclude(id=doc.id).exists():
+ return candidate
+ index += 1
def _rename_in_storage(self, old_name, new_name):
if old_name == new_name:
@@ -340,7 +494,7 @@ class Command(BaseCommand):
if minio_client.file_exists(new_name):
# Rename ya ocurrió en ejecución previa parcial
self.stderr.write(self.style.WARNING(
- f" ⚠ ERROR ya existe en storage, usando: {new_name}"
+ f" ⚠ Destino ya existe en storage, usando: {new_name}"
))
if minio_client.file_exists(old_name):
minio_client.delete_file(old_name)
@@ -367,12 +521,17 @@ class Command(BaseCommand):
# Resumen final
# ------------------------------------------------------------------ #
- def _print_summary(self, total_peds, total_partidas, total_docs, dry_run):
+ def _print_summary(self, total_peds, stats, dry_run):
self.stdout.write(
f"\n{'─' * 60}\nRESUMEN\n"
- f" Pedimentos malformados : {total_peds}\n"
- f" Partidas con descargado=True : {total_partidas}\n"
- f" Documentos con error VUCEM : {total_docs}\n"
+ f" Pedimentos procesados : {total_peds}\n"
+ f" Partidas revisadas (descargado=True) : {stats['partidas']}\n"
+ f" Partidas corregidas (descargado=False) : {stats['corregidas']}\n"
+ f" Partidas sin cambios (no verificables) : {stats['bloqueadas']}\n"
+ f" Docs renombrados a ERROR (type 18) : {stats['docs_error']}\n"
+ f" Docs reclasificados a REQUEST (type 17): {stats['docs_request']}\n"
+ f" Docs con contenido desconocido : {stats['desconocidos']}\n"
+ f" Registros en BD sin archivo en storage : {stats['fantasmas']} (no se borran)\n"
)
if dry_run:
self.stdout.write(self.style.WARNING(
diff --git a/api/customs/management/commands/reconciliar_descargas.py b/api/customs/management/commands/reconciliar_descargas.py
new file mode 100644
index 0000000..4da5b8f
--- /dev/null
+++ b/api/customs/management/commands/reconciliar_descargas.py
@@ -0,0 +1,110 @@
+from django.core.management.base import BaseCommand
+from django.db import transaction
+
+from api.customs.models import EDocument, Cove, EstadoDescarga
+from api.record.models import Document
+from api.utils.storage_service import storage_service
+
+
+class Command(BaseCommand):
+ """
+ Reconciliación de estatus de descarga VUCEM (T2026-05-027).
+
+ Detecta registros marcados como 'descargado' cuyo documento no existe en BD
+ o cuyo archivo falta físicamente en storage (MinIO), y los transiciona a
+ estado 'error' para que sean visibles y reprocesables. Sin --apply solo
+ reporta (dry-run).
+
+ Uso:
+ python manage.py reconciliar_descargas # reporte
+ python manage.py reconciliar_descargas --apply # corrige
+ python manage.py reconciliar_descargas --organizacion
+ """
+
+ help = "Reconcilia estatus de descarga de EDocs/COVEs contra documentos reales (BD + storage)"
+
+ # Catálogo confirmado de document_type:
+ # 4 = acuse EDoc, 7 = acuse COVE, 19/23 = request COVE, 21/25 = request EDoc,
+ # 20 = error COVE, 22 = error EDoc, 24 = error acuse COVE, 26 = error acuse EDoc
+ EXCLUIR_EDOC_GENERAL = [4, 21, 22, 25, 26]
+ EXCLUIR_COVE_GENERAL = [7, 19, 20, 23, 24]
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ '--apply', action='store_true',
+ help='Aplica las correcciones; sin esta bandera solo reporta (dry-run)'
+ )
+ parser.add_argument(
+ '--organizacion', type=str, default=None,
+ help='Limitar la reconciliación a una organización (UUID)'
+ )
+ parser.add_argument(
+ '--pedimento', type=str, default=None,
+ help='Limitar la reconciliación a un pedimento (UUID)'
+ )
+
+ def handle(self, *args, **opts):
+ apply_changes = opts['apply']
+ detectados = []
+
+ flujos = [
+ # (modelo, campo_estado, campo_intentos, etiqueta, fn_documentos)
+ (EDocument, 'acuse_estado', 'acuse_intentos', 'edoc.acuse',
+ lambda r: Document.objects.filter(
+ pedimento=r.pedimento,
+ archivo__icontains=r.numero_edocument,
+ document_type_id=4)),
+ (EDocument, 'edocument_estado', 'edocument_intentos', 'edoc.general',
+ lambda r: Document.objects.filter(
+ pedimento=r.pedimento,
+ archivo__icontains=r.numero_edocument,
+ ).exclude(document_type_id__in=self.EXCLUIR_EDOC_GENERAL)),
+ (Cove, 'acuse_cove_estado', 'acuse_cove_intentos', 'cove.acuse',
+ lambda r: Document.objects.filter(
+ pedimento=r.pedimento,
+ archivo__icontains=r.numero_cove,
+ document_type_id=7)),
+ (Cove, 'cove_estado', 'cove_intentos', 'cove.general',
+ lambda r: Document.objects.filter(
+ pedimento=r.pedimento,
+ archivo__icontains=r.numero_cove,
+ ).exclude(document_type_id__in=self.EXCLUIR_COVE_GENERAL)),
+ ]
+
+ for modelo, campo_estado, campo_intentos, etiqueta, fn_documentos in flujos:
+ qs = modelo.objects.filter(**{campo_estado: EstadoDescarga.DESCARGADO})
+ if opts['organizacion']:
+ qs = qs.filter(organizacion_id=opts['organizacion'])
+ if opts['pedimento']:
+ qs = qs.filter(pedimento_id=opts['pedimento'])
+
+ for registro in qs.select_related('pedimento').iterator():
+ numero = getattr(registro, 'numero_edocument', None) or registro.numero_cove
+ docs = fn_documentos(registro)
+ # Disponible = al menos un documento con fila en BD, tamaño > 0
+ # y archivo físicamente presente en storage
+ disponible = any(
+ doc.size and storage_service.file_exists(doc.archivo.name)
+ for doc in docs
+ )
+ if disponible:
+ continue
+
+ detectados.append((etiqueta, str(registro.id), numero, str(registro.pedimento_id)))
+ if apply_changes:
+ with transaction.atomic():
+ setattr(registro, campo_estado, EstadoDescarga.ERROR)
+ registro.ultimo_error = (
+ f"Reconciliación: {etiqueta} marcado como descargado "
+ f"sin archivo disponible en BD/storage"
+ )
+ # save() del modelo sincroniza el booleano legado
+ registro.save(update_fields=[campo_estado, 'ultimo_error'])
+
+ modo = 'CORREGIDOS' if apply_changes else 'DETECTADOS (dry-run, usa --apply para corregir)'
+ self.stdout.write(self.style.WARNING(f"{modo}: {len(detectados)}"))
+ for etiqueta, registro_id, numero, pedimento_id in detectados:
+ self.stdout.write(f" [{etiqueta}] id={registro_id} numero={numero} pedimento={pedimento_id}")
+
+ if not detectados:
+ self.stdout.write(self.style.SUCCESS("Sin inconsistencias: todos los 'descargado' tienen archivo disponible"))
diff --git a/api/customs/migrations/0020_estados_descarga_t2026_05_027.py b/api/customs/migrations/0020_estados_descarga_t2026_05_027.py
new file mode 100644
index 0000000..6b0c71e
--- /dev/null
+++ b/api/customs/migrations/0020_estados_descarga_t2026_05_027.py
@@ -0,0 +1,99 @@
+# Migración T2026-05-027: estados de descarga de 3 valores (pendiente/descargado/error)
+# y contador de intentos automáticos para EDocument y Cove.
+#
+# NO aplicar en automático. Después de aplicarla, ejecutar el backfill:
+# backend/scripts/t2026_05_027/02_backfill_estados.sql
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('customs', '0019_pedimento_consultar_vucem'),
+ ]
+
+ operations = [
+ # --- EDocument ---
+ migrations.AddField(
+ model_name='edocument',
+ name='edocument_estado',
+ field=models.CharField(choices=[('pendiente', 'Pendiente'), ('descargado', 'Descargado'), ('error', 'Error')], default='pendiente', help_text='Estado de descarga del e-documento: pendiente, descargado o error', max_length=12),
+ ),
+ migrations.AddField(
+ model_name='edocument',
+ name='acuse_estado',
+ field=models.CharField(choices=[('pendiente', 'Pendiente'), ('descargado', 'Descargado'), ('error', 'Error')], default='pendiente', help_text='Estado de descarga del acuse: pendiente, descargado o error', max_length=12),
+ ),
+ migrations.AddField(
+ model_name='edocument',
+ name='edocument_intentos',
+ field=models.PositiveSmallIntegerField(default=0, help_text='Intentos automáticos de descarga del e-documento (un ciclo de orquestación = un intento)'),
+ ),
+ migrations.AddField(
+ model_name='edocument',
+ name='acuse_intentos',
+ field=models.PositiveSmallIntegerField(default=0, help_text='Intentos automáticos de descarga del acuse (un ciclo de orquestación = un intento)'),
+ ),
+ migrations.AddField(
+ model_name='edocument',
+ name='ultimo_intento_at',
+ field=models.DateTimeField(blank=True, help_text='Fecha del último intento automático de descarga', null=True),
+ ),
+ migrations.AddField(
+ model_name='edocument',
+ name='ultimo_error',
+ field=models.TextField(blank=True, help_text='Detalle del último error de descarga', null=True),
+ ),
+ migrations.AlterField(
+ model_name='edocument',
+ name='edocument_descargado',
+ field=models.BooleanField(default=False, help_text='Indica si el e-documento ha sido descargado (legado, derivado de edocument_estado)'),
+ ),
+ migrations.AlterField(
+ model_name='edocument',
+ name='acuse_descargado',
+ field=models.BooleanField(default=False, help_text='Indica si el acuse del e-documento ha sido descargado (legado, derivado de acuse_estado)'),
+ ),
+ # --- Cove ---
+ migrations.AddField(
+ model_name='cove',
+ name='cove_estado',
+ field=models.CharField(choices=[('pendiente', 'Pendiente'), ('descargado', 'Descargado'), ('error', 'Error')], default='pendiente', help_text='Estado de descarga de la cove: pendiente, descargado o error', max_length=12),
+ ),
+ migrations.AddField(
+ model_name='cove',
+ name='acuse_cove_estado',
+ field=models.CharField(choices=[('pendiente', 'Pendiente'), ('descargado', 'Descargado'), ('error', 'Error')], default='pendiente', help_text='Estado de descarga del acuse de la cove: pendiente, descargado o error', max_length=12),
+ ),
+ migrations.AddField(
+ model_name='cove',
+ name='cove_intentos',
+ field=models.PositiveSmallIntegerField(default=0, help_text='Intentos automáticos de descarga de la cove (un ciclo de orquestación = un intento)'),
+ ),
+ migrations.AddField(
+ model_name='cove',
+ name='acuse_cove_intentos',
+ field=models.PositiveSmallIntegerField(default=0, help_text='Intentos automáticos de descarga del acuse de la cove (un ciclo de orquestación = un intento)'),
+ ),
+ migrations.AddField(
+ model_name='cove',
+ name='ultimo_intento_at',
+ field=models.DateTimeField(blank=True, help_text='Fecha del último intento automático de descarga', null=True),
+ ),
+ migrations.AddField(
+ model_name='cove',
+ name='ultimo_error',
+ field=models.TextField(blank=True, help_text='Detalle del último error de descarga', null=True),
+ ),
+ migrations.AlterField(
+ model_name='cove',
+ name='cove_descargado',
+ field=models.BooleanField(default=False, help_text='Indica si la cove ha sido descargada (legado, derivado de cove_estado)'),
+ ),
+ migrations.AlterField(
+ model_name='cove',
+ name='acuse_cove_descargado',
+ field=models.BooleanField(default=False, help_text='Indica si el acuse de la cove ha sido descargado (legado, derivado de acuse_cove_estado)'),
+ ),
+ ]
diff --git a/api/customs/models.py b/api/customs/models.py
index 748516b..15bd01a 100644
--- a/api/customs/models.py
+++ b/api/customs/models.py
@@ -66,6 +66,13 @@ class Pedimento(models.Model):
['organizacion', 'pedimento_app']
]
+class EstadoDescarga(models.TextChoices):
+ """Estado de descarga de documentos VUCEM (requerimiento T2026-05-027):
+ 'error' indica que la descarga no pudo completarse y requiere atención."""
+ PENDIENTE = 'pendiente', 'Pendiente'
+ DESCARGADO = 'descargado', 'Descargado'
+ ERROR = 'error', 'Error'
+
class Partida(models.Model):
pedimento = models.ForeignKey(Pedimento, on_delete=models.CASCADE, related_name='partidas', help_text="Pedimento asociado a la partida")
organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='partidas', help_text="Organización a la que pertenece la partida")
@@ -94,8 +101,28 @@ class EDocument(models.Model):
descripcion = models.CharField(max_length=200, blank=True, null=True, help_text="Descripción del documento")
created_at = models.DateTimeField(auto_now_add=True, help_text="Fecha de creación del documento")
updated_at = models.DateTimeField(auto_now=True, help_text="Fecha de última actualización del documento")
- edocument_descargado = models.BooleanField(default=False, help_text="Indica si el e-documento ha sido descargado")
- acuse_descargado = models.BooleanField(default=False, help_text="Indica si el acuse del e-documento ha sido descargado")
+ edocument_descargado = models.BooleanField(default=False, help_text="Indica si el e-documento ha sido descargado (legado, derivado de edocument_estado)")
+ acuse_descargado = models.BooleanField(default=False, help_text="Indica si el acuse del e-documento ha sido descargado (legado, derivado de acuse_estado)")
+ edocument_estado = models.CharField(max_length=12, choices=EstadoDescarga.choices, default=EstadoDescarga.PENDIENTE, help_text="Estado de descarga del e-documento: pendiente, descargado o error")
+ acuse_estado = models.CharField(max_length=12, choices=EstadoDescarga.choices, default=EstadoDescarga.PENDIENTE, help_text="Estado de descarga del acuse: pendiente, descargado o error")
+ edocument_intentos = models.PositiveSmallIntegerField(default=0, help_text="Intentos automáticos de descarga del e-documento (un ciclo de orquestación = un intento)")
+ acuse_intentos = models.PositiveSmallIntegerField(default=0, help_text="Intentos automáticos de descarga del acuse (un ciclo de orquestación = un intento)")
+ ultimo_intento_at = models.DateTimeField(null=True, blank=True, help_text="Fecha del último intento automático de descarga")
+ ultimo_error = models.TextField(null=True, blank=True, help_text="Detalle del último error de descarga")
+
+ def save(self, *args, **kwargs):
+ # El estado de 3 valores es la fuente de verdad; los booleanos legados se derivan
+ self.edocument_descargado = self.edocument_estado == EstadoDescarga.DESCARGADO
+ self.acuse_descargado = self.acuse_estado == EstadoDescarga.DESCARGADO
+ update_fields = kwargs.get('update_fields')
+ if update_fields is not None:
+ update_fields = set(update_fields)
+ if 'edocument_estado' in update_fields:
+ update_fields.add('edocument_descargado')
+ if 'acuse_estado' in update_fields:
+ update_fields.add('acuse_descargado')
+ kwargs['update_fields'] = list(update_fields)
+ super().save(*args, **kwargs)
def __str__(self):
return f"{self.descripcion} - {self.pedimento.pedimento}"
@@ -112,8 +139,28 @@ class Cove(models.Model):
numero_cove = models.CharField(max_length=20, unique=True, help_text="Número único de la cove")
created_at = models.DateTimeField(auto_now_add=True, help_text="Fecha de creación de la cove")
updated_at = models.DateTimeField(auto_now=True, help_text="Fecha de última actualización de la cove")
- cove_descargado = models.BooleanField(default=False, help_text="Indica si la cove ha sido descargada")
- acuse_cove_descargado = models.BooleanField(default=False, help_text="Indica si el acuse de la cove ha sido descargado")
+ cove_descargado = models.BooleanField(default=False, help_text="Indica si la cove ha sido descargada (legado, derivado de cove_estado)")
+ acuse_cove_descargado = models.BooleanField(default=False, help_text="Indica si el acuse de la cove ha sido descargado (legado, derivado de acuse_cove_estado)")
+ cove_estado = models.CharField(max_length=12, choices=EstadoDescarga.choices, default=EstadoDescarga.PENDIENTE, help_text="Estado de descarga de la cove: pendiente, descargado o error")
+ acuse_cove_estado = models.CharField(max_length=12, choices=EstadoDescarga.choices, default=EstadoDescarga.PENDIENTE, help_text="Estado de descarga del acuse de la cove: pendiente, descargado o error")
+ cove_intentos = models.PositiveSmallIntegerField(default=0, help_text="Intentos automáticos de descarga de la cove (un ciclo de orquestación = un intento)")
+ acuse_cove_intentos = models.PositiveSmallIntegerField(default=0, help_text="Intentos automáticos de descarga del acuse de la cove (un ciclo de orquestación = un intento)")
+ ultimo_intento_at = models.DateTimeField(null=True, blank=True, help_text="Fecha del último intento automático de descarga")
+ ultimo_error = models.TextField(null=True, blank=True, help_text="Detalle del último error de descarga")
+
+ def save(self, *args, **kwargs):
+ # El estado de 3 valores es la fuente de verdad; los booleanos legados se derivan
+ self.cove_descargado = self.cove_estado == EstadoDescarga.DESCARGADO
+ self.acuse_cove_descargado = self.acuse_cove_estado == EstadoDescarga.DESCARGADO
+ update_fields = kwargs.get('update_fields')
+ if update_fields is not None:
+ update_fields = set(update_fields)
+ if 'cove_estado' in update_fields:
+ update_fields.add('cove_descargado')
+ if 'acuse_cove_estado' in update_fields:
+ update_fields.add('acuse_cove_descargado')
+ kwargs['update_fields'] = list(update_fields)
+ super().save(*args, **kwargs)
def __str__(self):
return f"{self.numero_cove} - {self.pedimento.pedimento}"
diff --git a/api/customs/serializers.py b/api/customs/serializers.py
index c0bb127..6ac8be5 100644
--- a/api/customs/serializers.py
+++ b/api/customs/serializers.py
@@ -1,12 +1,13 @@
from rest_framework import serializers
from api.customs.models import (
- Pedimento,
- TipoOperacion,
- ProcesamientoPedimento,
+ Pedimento,
+ TipoOperacion,
+ ProcesamientoPedimento,
EDocument,
Cove,
Importador,
- Partida
+ Partida,
+ EstadoDescarga
)
from django.db import models
from django.db.models import Q
@@ -205,7 +206,23 @@ class EDocumentSerializer(serializers.ModelSerializer):
model = EDocument
fields = '__all__'
read_only_fields = ('created_at', 'updated_at')
-
+
+ def validate(self, attrs):
+ # Compatibilidad: payloads legados que solo mandan los booleanos se traducen
+ # al estado de 3 valores (fuente de verdad en el modelo). Un False legado no
+ # degrada un estado 'error' ya asignado.
+ if 'edocument_descargado' in attrs and 'edocument_estado' not in attrs:
+ if attrs['edocument_descargado']:
+ attrs['edocument_estado'] = EstadoDescarga.DESCARGADO
+ elif not (self.instance and self.instance.edocument_estado == EstadoDescarga.ERROR):
+ attrs['edocument_estado'] = EstadoDescarga.PENDIENTE
+ if 'acuse_descargado' in attrs and 'acuse_estado' not in attrs:
+ if attrs['acuse_descargado']:
+ attrs['acuse_estado'] = EstadoDescarga.DESCARGADO
+ elif not (self.instance and self.instance.acuse_estado == EstadoDescarga.ERROR):
+ attrs['acuse_estado'] = EstadoDescarga.PENDIENTE
+ return attrs
+
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Si no es superusuario, hacer organizacion read_only
@@ -221,6 +238,22 @@ class CoveSerializer(serializers.ModelSerializer):
fields = '__all__'
read_only_fields = ('created_at', 'updated_at')
+ def validate(self, attrs):
+ # Compatibilidad: payloads legados que solo mandan los booleanos se traducen
+ # al estado de 3 valores (fuente de verdad en el modelo). Un False legado no
+ # degrada un estado 'error' ya asignado.
+ if 'cove_descargado' in attrs and 'cove_estado' not in attrs:
+ if attrs['cove_descargado']:
+ attrs['cove_estado'] = EstadoDescarga.DESCARGADO
+ elif not (self.instance and self.instance.cove_estado == EstadoDescarga.ERROR):
+ attrs['cove_estado'] = EstadoDescarga.PENDIENTE
+ if 'acuse_cove_descargado' in attrs and 'acuse_cove_estado' not in attrs:
+ if attrs['acuse_cove_descargado']:
+ attrs['acuse_cove_estado'] = EstadoDescarga.DESCARGADO
+ elif not (self.instance and self.instance.acuse_cove_estado == EstadoDescarga.ERROR):
+ attrs['acuse_cove_estado'] = EstadoDescarga.PENDIENTE
+ return attrs
+
def get_documentos(self, obj):
"""
Busca documentos en la tabla `document` que coincidan con el
diff --git a/api/customs/tasks/auditoria.py b/api/customs/tasks/auditoria.py
index 3a95e98..816f700 100644
--- a/api/customs/tasks/auditoria.py
+++ b/api/customs/tasks/auditoria.py
@@ -988,25 +988,61 @@ def auditar_integridad_coves_por_pedimento(pedimento_id):
@shared_task
def auditar_integridad_remesa_por_pedimento(pedimento_id):
- """Verifica que los COVEs del XML de remesa existan en DB para un pedimento específico."""
+ """Verifica que los COVEs del XML de remesa existan en DB para un pedimento específico.
+
+ Deduce si el pedimento es consolidado desde el identificador PC del XML del
+ pedimento completo (fuente de verdad) en lugar del flag `remesas`. Si es
+ consolidado y no hay documento de remesa descargado, dispara la consulta a VUCEM.
+ """
+ # Import local para evitar import circular (internal_services importa de auditoria)
+ from api.customs.tasks.internal_services import crear_procesamiento_remesa
+
try:
pedimento = Pedimento.objects.get(id=pedimento_id)
- if not pedimento.remesas:
+ xml_pc = _leer_xml_pedimento_completo(pedimento)
+ if not xml_pc:
+ return {
+ 'pedimento_id': str(pedimento_id),
+ 'pedimento': pedimento.pedimento,
+ 'estado': 'sin_xml_pc',
+ 'mensaje': 'No hay pedimento completo (document_type=2) descargado',
+ }
+
+ xml_data = xml_controller.extract_data(xml_pc)
+ if not xml_data:
+ return {
+ 'pedimento_id': str(pedimento_id),
+ 'pedimento': pedimento.pedimento,
+ 'estado': 'error',
+ 'mensaje': 'No se pudieron extraer datos del XML del pedimento completo',
+ }
+
+ tiene_remesas = bool(xml_data.get('remesas'))
+
+ # Sincronizar el flag con queryset.update() para no disparar el signal
+ # post_save; la consulta a VUCEM se dispara explícitamente abajo
+ if tiene_remesas != pedimento.remesas:
+ Pedimento.objects.filter(id=pedimento.id).update(remesas=tiene_remesas)
+ pedimento.remesas = tiene_remesas
+
+ if not tiene_remesas:
return {
'pedimento_id': str(pedimento_id),
'pedimento': pedimento.pedimento,
'estado': 'sin_remesas',
- 'mensaje': 'Este pedimento no tiene remesas',
+ 'mensaje': 'El pedimento completo no declara identificador PC (consolidado)',
}
doc_remesa = pedimento.documents.filter(document_type=3).first()
if not doc_remesa:
+ # Consolidado sin XML de remesa: solicitar la descarga a VUCEM
+ crear_procesamiento_remesa.apply_async(args=[str(pedimento.id)])
return {
'pedimento_id': str(pedimento_id),
'pedimento': pedimento.pedimento,
- 'estado': 'sin_xml',
- 'mensaje': 'No hay documento de remesa (document_type=3) descargado',
+ 'estado': 'descarga_solicitada',
+ 'mensaje': 'Pedimento consolidado sin documento de remesa; se solicitó la consulta a VUCEM',
}
remesa_xml = _leer_xml_documento(doc_remesa)
diff --git a/api/customs/tasks/microservice_v2.py b/api/customs/tasks/microservice_v2.py
index 8e1daa4..7cbebe4 100644
--- a/api/customs/tasks/microservice_v2.py
+++ b/api/customs/tasks/microservice_v2.py
@@ -5,8 +5,10 @@ from api.customs.models import *
from api.record.models import *
from api.customs.serializers import PedimentoSerializer
from api.vucem.models import *
+from django.db.models import F
+from django.utils import timezone
import requests
-from config.settings import SERVICE_API_URL_V2
+from config.settings import SERVICE_API_URL_V2, MAX_INTENTOS_AUTO
from datetime import datetime
import json
import logging
@@ -77,16 +79,18 @@ def partida_to_dict(partida):
@shared_task
def procesar_coves_pedimento(pedimento_id):
+ # Flujo manual: incluye registros en 'error' y no aplica tope ni contador de intentos
pedimento = Pedimento.objects.get(id=pedimento_id)
- if pedimento.coves.filter(cove_descargado=False).exists():
+ estados_reprocesables = [EstadoDescarga.PENDIENTE, EstadoDescarga.ERROR]
+ if pedimento.coves.filter(cove_estado__in=estados_reprocesables).exists():
pedimento_dict = pedimento_to_dict(pedimento)
credenciales = Vucem.objects.filter(
id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id
).first()
credenciales_dict = credenciales_to_dict(credenciales)
-
+
payload = {
- "coves": [cove_to_dict(cove) for cove in pedimento.coves.filter(cove_descargado=False)],
+ "coves": [cove_to_dict(cove) for cove in pedimento.coves.filter(cove_estado__in=estados_reprocesables)],
"pedimento": pedimento_dict,
"credencial": credenciales_dict
}
@@ -106,8 +110,10 @@ def procesar_coves_pedimento(pedimento_id):
@shared_task
def procesar_acuse_coves_pedimento(pedimento_id):
+ # Flujo manual: incluye registros en 'error' y no aplica tope ni contador de intentos
pedimento = Pedimento.objects.get(id=pedimento_id)
- if pedimento.coves.filter(acuse_cove_descargado=False).exists():
+ estados_reprocesables = [EstadoDescarga.PENDIENTE, EstadoDescarga.ERROR]
+ if pedimento.coves.filter(acuse_cove_estado__in=estados_reprocesables).exists():
pedimento_dict = pedimento_to_dict(pedimento)
credenciales = Vucem.objects.filter(
id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id
@@ -115,7 +121,7 @@ def procesar_acuse_coves_pedimento(pedimento_id):
credenciales_dict = credenciales_to_dict(credenciales)
payload = {
- "coves": [cove_to_dict(cove) for cove in pedimento.coves.filter(acuse_cove_descargado=False)],
+ "coves": [cove_to_dict(cove) for cove in pedimento.coves.filter(acuse_cove_estado__in=estados_reprocesables)],
"pedimento": pedimento_dict,
"credencial": credenciales_dict
}
@@ -135,8 +141,10 @@ def procesar_acuse_coves_pedimento(pedimento_id):
@shared_task
def procesar_edocs_pedimento(pedimento_id):
+ # Flujo manual: incluye registros en 'error' y no aplica tope ni contador de intentos
pedimento = Pedimento.objects.get(id=pedimento_id)
- if pedimento.documentos.filter(edocument_descargado=False).exists():
+ estados_reprocesables = [EstadoDescarga.PENDIENTE, EstadoDescarga.ERROR]
+ if pedimento.documentos.filter(edocument_estado__in=estados_reprocesables).exists():
pedimento_dict = pedimento_to_dict(pedimento)
credenciales = Vucem.objects.filter(
id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id
@@ -144,7 +152,7 @@ def procesar_edocs_pedimento(pedimento_id):
credenciales_dict = credenciales_to_dict(credenciales)
payload = {
- "edocs": [edoc_to_dict(edoc) for edoc in pedimento.documentos.filter(edocument_descargado=False)],
+ "edocs": [edoc_to_dict(edoc) for edoc in pedimento.documentos.filter(edocument_estado__in=estados_reprocesables)],
"pedimento": pedimento_dict,
"credencial": credenciales_dict
}
@@ -164,8 +172,10 @@ def procesar_edocs_pedimento(pedimento_id):
@shared_task
def procesar_acuses_pedimento(pedimento_id):
+ # Flujo manual: incluye registros en 'error' y no aplica tope ni contador de intentos
pedimento = Pedimento.objects.get(id=pedimento_id)
- if pedimento.documentos.filter(acuse_descargado=False).exists():
+ estados_reprocesables = [EstadoDescarga.PENDIENTE, EstadoDescarga.ERROR]
+ if pedimento.documentos.filter(acuse_estado__in=estados_reprocesables).exists():
pedimento_dict = pedimento_to_dict(pedimento)
credenciales = Vucem.objects.filter(
id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id
@@ -173,7 +183,7 @@ def procesar_acuses_pedimento(pedimento_id):
credenciales_dict = credenciales_to_dict(credenciales)
payload = {
- "edocs": [edoc_to_dict(edoc) for edoc in pedimento.documentos.filter(acuse_descargado=False)],
+ "edocs": [edoc_to_dict(edoc) for edoc in pedimento.documentos.filter(acuse_estado__in=estados_reprocesables)],
"pedimento": pedimento_dict,
"credencial": credenciales_dict
}
@@ -381,20 +391,31 @@ def procesar_coves(organizacion_id):
coves__isnull=False
).distinct()
for pedimento in pedimentos:
- if pedimento.coves.filter(cove_descargado=False).exists(): # Tipo 3: Remesa
+ # Compuerta del automático (T2026-05-027): solo 'pendiente' con intentos disponibles;
+ # registros en 'error' o con tope agotado solo se relanzan de forma manual
+ pendientes = pedimento.coves.filter(
+ cove_estado=EstadoDescarga.PENDIENTE,
+ cove_intentos__lt=MAX_INTENTOS_AUTO,
+ )
+ coves_batch = list(pendientes)
+ if coves_batch:
# 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_dict = credenciales_to_dict(credenciales)
-
+
payload = {
- "coves": [cove_to_dict(cove) for cove in pedimento.coves.filter(cove_descargado=False)],
+ "coves": [cove_to_dict(cove) for cove in coves_batch],
"pedimento": pedimento_dict,
"credencial": credenciales_dict
}
-
+
+ # Un ciclo de orquestación = un intento; los reintentos internos
+ # del worker (Celery/SOAP) pertenecen a este mismo intento
+ pendientes.update(cove_intentos=F('cove_intentos') + 1, ultimo_intento_at=timezone.now())
+
try:
response = requests.post(
f"{SERVICE_API_URL_V2}/services/all/coves",
@@ -416,20 +437,29 @@ def procesar_acuse_coves(organizacion_id):
).distinct()
for pedimento in pedimentos:
- if pedimento.coves.filter(acuse_cove_descargado=False).exists(): # Tipo 3: Remesa
+ # Compuerta del automático (T2026-05-027): solo 'pendiente' con intentos disponibles
+ pendientes = pedimento.coves.filter(
+ acuse_cove_estado=EstadoDescarga.PENDIENTE,
+ acuse_cove_intentos__lt=MAX_INTENTOS_AUTO,
+ )
+ coves_batch = list(pendientes)
+ if coves_batch:
# 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_dict = credenciales_to_dict(credenciales)
-
+
payload = {
- "coves": [cove_to_dict(cove) for cove in pedimento.coves.filter(acuse_cove_descargado=False)],
+ "coves": [cove_to_dict(cove) for cove in coves_batch],
"pedimento": pedimento_dict,
"credencial": credenciales_dict
}
-
+
+ # Un ciclo de orquestación = un intento
+ pendientes.update(acuse_cove_intentos=F('acuse_cove_intentos') + 1, ultimo_intento_at=timezone.now())
+
try:
response = requests.post(
f"{SERVICE_API_URL_V2}/services/all/acuse/cove/",
@@ -451,20 +481,29 @@ def procesar_acuses(organizacion_id):
).distinct()
for pedimento in pedimentos:
- if pedimento.documentos.filter(acuse_descargado=False).exists(): # Tipo 3: Remesa
+ # Compuerta del automático (T2026-05-027): solo 'pendiente' con intentos disponibles
+ pendientes = pedimento.documentos.filter(
+ acuse_estado=EstadoDescarga.PENDIENTE,
+ acuse_intentos__lt=MAX_INTENTOS_AUTO,
+ )
+ edocs_batch = list(pendientes)
+ if edocs_batch:
# 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_dict = credenciales_to_dict(credenciales)
-
+
payload = {
- "edocs": [edoc_to_dict(edoc) for edoc in pedimento.documentos.filter(acuse_descargado=False)],
+ "edocs": [edoc_to_dict(edoc) for edoc in edocs_batch],
"pedimento": pedimento_dict,
"credencial": credenciales_dict
}
+ # Un ciclo de orquestación = un intento
+ pendientes.update(acuse_intentos=F('acuse_intentos') + 1, ultimo_intento_at=timezone.now())
+
try:
response = requests.post(
f"{SERVICE_API_URL_V2}/services/all/acuse/pedimento/",
@@ -486,20 +525,29 @@ def procesar_edocs(organizacion_id):
).distinct()
for pedimento in pedimentos:
- if pedimento.documentos.filter(edocument_descargado=False).exists(): # Tipo 3: Remesa
+ # Compuerta del automático (T2026-05-027): solo 'pendiente' con intentos disponibles
+ pendientes = pedimento.documentos.filter(
+ edocument_estado=EstadoDescarga.PENDIENTE,
+ edocument_intentos__lt=MAX_INTENTOS_AUTO,
+ )
+ edocs_batch = list(pendientes)
+ if edocs_batch:
# 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_dict = credenciales_to_dict(credenciales)
-
+
payload = {
- "edocs": [edoc_to_dict(edoc) for edoc in pedimento.documentos.filter(edocument_descargado=False)],
+ "edocs": [edoc_to_dict(edoc) for edoc in edocs_batch],
"pedimento": pedimento_dict,
"credencial": credenciales_dict
}
+ # Un ciclo de orquestación = un intento
+ pendientes.update(edocument_intentos=F('edocument_intentos') + 1, ultimo_intento_at=timezone.now())
+
try:
response = requests.post(
f"{SERVICE_API_URL_V2}/services/download/all/edocs/",
@@ -660,3 +708,59 @@ def process_all_organizations():
)
return f"Dispatched {active_orgs.count()} organizations"
+@shared_task
+def reintentar_descargas_pendientes():
+ """
+ Reintento recurrente de descargas VUCEM (T2026-05-027): transiciona a 'error'
+ los registros que agotaron MAX_INTENTOS_AUTO y relanza los pendientes por
+ organización. El incremento del contador vive en las tareas procesar_*
+ (puerta común de todos los flujos automáticos), por lo que aquí solo se orquesta.
+ """
+ ahora = timezone.now()
+ mensaje_tope = (
+ f"Se agotaron {MAX_INTENTOS_AUTO} intentos automáticos de descarga; "
+ f"requiere reproceso manual"
+ )
+
+ # 1) Transicionar a 'error' lo que agotó el tope automático.
+ # update() no pasa por save(): sincronizar también el booleano legado y updated_at.
+ edocs_err = EDocument.objects.filter(
+ edocument_estado=EstadoDescarga.PENDIENTE,
+ edocument_intentos__gte=MAX_INTENTOS_AUTO,
+ ).update(edocument_estado=EstadoDescarga.ERROR, edocument_descargado=False,
+ ultimo_error=mensaje_tope, updated_at=ahora)
+ acuses_err = EDocument.objects.filter(
+ acuse_estado=EstadoDescarga.PENDIENTE,
+ acuse_intentos__gte=MAX_INTENTOS_AUTO,
+ ).update(acuse_estado=EstadoDescarga.ERROR, acuse_descargado=False,
+ ultimo_error=mensaje_tope, updated_at=ahora)
+ coves_err = Cove.objects.filter(
+ cove_estado=EstadoDescarga.PENDIENTE,
+ cove_intentos__gte=MAX_INTENTOS_AUTO,
+ ).update(cove_estado=EstadoDescarga.ERROR, cove_descargado=False,
+ ultimo_error=mensaje_tope, updated_at=ahora)
+ acuse_coves_err = Cove.objects.filter(
+ acuse_cove_estado=EstadoDescarga.PENDIENTE,
+ acuse_cove_intentos__gte=MAX_INTENTOS_AUTO,
+ ).update(acuse_cove_estado=EstadoDescarga.ERROR, acuse_cove_descargado=False,
+ ultimo_error=mensaje_tope, updated_at=ahora)
+
+ if edocs_err or acuses_err or coves_err or acuse_coves_err:
+ logger.info(
+ f"Tope de intentos agotado -> error: edocs={edocs_err}, acuses={acuses_err}, "
+ f"coves={coves_err}, acuse_coves={acuse_coves_err}"
+ )
+
+ # 2) Relanzar por organización (procesar_* aplica la compuerta e incrementa el contador)
+ 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"Reintentos despachados para {active_orgs.count()} organizaciones"
+
diff --git a/api/customs/tests.py b/api/customs/tests.py
index 959adcb..1085bf6 100644
--- a/api/customs/tests.py
+++ b/api/customs/tests.py
@@ -224,3 +224,275 @@ class BulkCreateDocumentReplaceTests(APITestCase):
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)
+
+
+# ---------------------------------------------------------------------------
+# Tests del comando fix_partidas_error
+# Una partida descargado=True solo es válida si alguno de sus documentos
+# contiene consultarPartidaRespuesta sin tieneError=true. Partidas que solo
+# tienen el REQUEST (o errores) deben volver a descargado=False.
+# ---------------------------------------------------------------------------
+
+from io import StringIO
+from types import SimpleNamespace
+from django.core.management import call_command
+from django.test import TestCase
+
+
+XML_RESPUESTA_VALIDA = (
+ ""
+ ''
+ ''
+ "false"
+ ""
+)
+
+XML_ERROR_VUCEM = (
+ ""
+ ''
+ ''
+ "true"
+ ""
+)
+
+XML_ECO_REQUEST = (
+ ""
+ ''
+ ""
+ ""
+)
+
+
+class _FakeMinioObject:
+ """Simula el objeto retornado por minio get_object."""
+
+ def __init__(self, content):
+ self._content = content
+
+ def read(self):
+ return self._content
+
+ def close(self):
+ pass
+
+ def release_conn(self):
+ pass
+
+
+class FixPartidasErrorCommandTests(TestCase):
+ PED_APP = "24-01-3420-1234567"
+
+ def setUp(self):
+ from api.customs.models import Partida
+ from api.record.models import DocumentType
+
+ self.licencia = Licencia.objects.create(nombre="LicFixPartidas", almacenamiento=100)
+ self.org = Organizacion.objects.create(
+ nombre="OrgFixPartidas", licencia=self.licencia, is_active=True, is_verified=True
+ )
+ # Pedimento VÁLIDO (no malformado): el comando ya no se limita a malformados
+ self.pedimento = Pedimento.objects.create(
+ organizacion=self.org,
+ pedimento="1234567",
+ pedimento_app=self.PED_APP,
+ aduana="034",
+ patente="3420",
+ numero_operacion="12345678",
+ )
+ self.partida = Partida.objects.create(
+ pedimento=self.pedimento,
+ organizacion=self.org,
+ numero_partida=1,
+ descargado=True,
+ )
+ self.type_resp = DocumentType.objects.get_or_create(id=1, defaults={"nombre": "XML"})[0]
+ self.type_req = DocumentType.objects.get_or_create(id=17, defaults={"nombre": "PT Request"})[0]
+ self.type_err = DocumentType.objects.get_or_create(id=18, defaults={"nombre": "PT Error"})[0]
+
+ # Storage simulado: dict path -> bytes
+ self.storage = {}
+ patcher = patch("api.customs.management.commands.fix_partidas_error.minio_client")
+ self.minio = patcher.start()
+ self.addCleanup(patcher.stop)
+ self.minio._bucket_name = "test-bucket"
+ self.minio.file_exists.side_effect = lambda name: name in self.storage
+ self.minio._client.get_object.side_effect = (
+ lambda bucket, name: _FakeMinioObject(self.storage[name])
+ )
+ self.minio.upload_file.side_effect = (
+ lambda name, file_data=None, content_type=None: self.storage.__setitem__(
+ name, file_data.read()
+ )
+ )
+ self.minio.delete_file.side_effect = lambda name: self.storage.pop(name, None)
+
+ def _doc(self, filename, doc_type, content=None):
+ from api.record.models import Document
+
+ path = f"org/{self.PED_APP}/{filename}"
+ doc = Document.objects.create(
+ organizacion=self.org,
+ pedimento=self.pedimento,
+ document_type=doc_type,
+ archivo=path,
+ size=100,
+ extension="xml",
+ )
+ if content is not None:
+ self.storage[path] = content.encode("utf-8")
+ return doc
+
+ def _run(self, **kwargs):
+ out = StringIO()
+ call_command("fix_partidas_error", stdout=out, stderr=StringIO(), **kwargs)
+ return out.getvalue()
+
+ def test_partida_solo_request_se_marca_no_descargada(self):
+ """El caso reportado: descargado=True pero solo existe el XML del REQUEST."""
+ self._doc(f"vu_PT_{self.PED_APP}_1_REQUEST.xml", self.type_req, XML_ECO_REQUEST)
+
+ self._run(pedimento=str(self.pedimento.id))
+
+ self.partida.refresh_from_db()
+ self.assertFalse(self.partida.descargado)
+
+ def test_partida_sin_documentos_se_marca_no_descargada(self):
+ """descargado=True sin ningún documento tampoco es una descarga real."""
+ self._run(pedimento=str(self.pedimento.id))
+
+ self.partida.refresh_from_db()
+ self.assertFalse(self.partida.descargado)
+
+ def test_partida_con_respuesta_valida_permanece_descargada(self):
+ """Con consultarPartidaRespuesta sin error la partida no se toca."""
+ self._doc(f"vu_PT_{self.PED_APP}_1_REQUEST.xml", self.type_req, XML_ECO_REQUEST)
+ self._doc(f"vu_PT_{self.PED_APP}_1.xml", self.type_resp, XML_RESPUESTA_VALIDA)
+
+ self._run(pedimento=str(self.pedimento.id))
+
+ self.partida.refresh_from_db()
+ self.assertTrue(self.partida.descargado)
+
+ def test_doc_con_error_vucem_se_renombra_y_marca_no_descargada(self):
+ """tieneError=true: doc → type 18 con sufijo _ERROR y partida → False."""
+ doc = self._doc(f"vu_PT_{self.PED_APP}_1.xml", self.type_resp, XML_ERROR_VUCEM)
+ old_path = doc.archivo.name
+
+ self._run(pedimento=str(self.pedimento.id))
+
+ self.partida.refresh_from_db()
+ doc.refresh_from_db()
+ self.assertFalse(self.partida.descargado)
+ self.assertEqual(doc.document_type_id, 18)
+ self.assertTrue(doc.archivo.name.endswith(f"vu_PT_{self.PED_APP}_1_ERROR.xml"))
+ self.assertTrue(doc.vu)
+ self.assertNotIn(old_path, self.storage)
+ self.assertIn(doc.archivo.name, self.storage)
+
+ def test_eco_de_request_guardado_como_respuesta_se_reclasifica(self):
+ """Un eco de consultarPartidaPeticion guardado como respuesta se
+ reclasifica a type 17 sin chocar con el REQUEST real existente."""
+ self._doc(f"vu_PT_{self.PED_APP}_1_REQUEST.xml", self.type_req, XML_ECO_REQUEST)
+ doc = self._doc(f"vu_PT_{self.PED_APP}_1.xml", self.type_resp, XML_ECO_REQUEST)
+
+ self._run(pedimento=str(self.pedimento.id))
+
+ self.partida.refresh_from_db()
+ doc.refresh_from_db()
+ self.assertFalse(self.partida.descargado)
+ self.assertEqual(doc.document_type_id, 17)
+ # El nombre sin índice ya lo usa el REQUEST real → debe ir con _1
+ self.assertTrue(doc.archivo.name.endswith(f"vu_PT_{self.PED_APP}_1_REQUEST_1.xml"))
+
+ def test_doc_ausente_sin_canario_no_cambia_partida(self):
+ """Archivo ausente y NINGÚN archivo del pedimento en storage: posible
+ storage equivocado (p. ej. dev) → sin cambios."""
+ self._doc(f"vu_PT_{self.PED_APP}_1.xml", self.type_resp, content=None)
+
+ self._run(pedimento=str(self.pedimento.id))
+
+ self.partida.refresh_from_db()
+ self.assertTrue(self.partida.descargado)
+
+ def test_registro_fantasma_con_storage_real_se_marca_no_descargada(self):
+ """Document type 1 en BD sin archivo en storage, pero el REQUEST sí
+ existe físicamente (canario): el storage es el correcto, el registro es
+ fantasma → la partida no tiene XML de partida → descargado=False."""
+ self._doc(f"vu_PT_{self.PED_APP}_1_REQUEST.xml", self.type_req, XML_ECO_REQUEST)
+ fantasma = self._doc(f"vu_PT_{self.PED_APP}_1.xml", self.type_resp, content=None)
+
+ self._run(pedimento=str(self.pedimento.id))
+
+ self.partida.refresh_from_db()
+ fantasma.refresh_from_db()
+ self.assertFalse(self.partida.descargado)
+ # El registro fantasma se reporta pero no se modifica ni se borra
+ self.assertEqual(fantasma.document_type_id, 1)
+
+ def test_storage_inaccesible_no_cambia_partida(self):
+ """Excepción al consultar storage (conexión caída): sin cambios."""
+ self._doc(f"vu_PT_{self.PED_APP}_1.xml", self.type_resp, XML_ERROR_VUCEM)
+ self.minio.file_exists.side_effect = Exception("connection refused")
+
+ self._run(pedimento=str(self.pedimento.id))
+
+ self.partida.refresh_from_db()
+ self.assertTrue(self.partida.descargado)
+
+ def test_naming_legacy_valida_partida(self):
+ """Documentos con nomenclatura legacy (partida al final) también validan."""
+ self._doc("vu_PT_010Imp_034_3420_1234567_1.xml", self.type_resp, XML_RESPUESTA_VALIDA)
+
+ self._run(pedimento=str(self.pedimento.id))
+
+ self.partida.refresh_from_db()
+ self.assertTrue(self.partida.descargado)
+
+ def test_dry_run_no_modifica(self):
+ """--dry-run reporta pero no toca BD ni storage."""
+ doc = self._doc(f"vu_PT_{self.PED_APP}_1.xml", self.type_resp, XML_ERROR_VUCEM)
+
+ self._run(pedimento=str(self.pedimento.id), dry_run=True)
+
+ self.partida.refresh_from_db()
+ doc.refresh_from_db()
+ self.assertTrue(self.partida.descargado)
+ self.assertEqual(doc.document_type_id, 1)
+ self.assertIn(doc.archivo.name, self.storage)
+
+ def test_universo_general_incluye_pedimentos_validos(self):
+ """Sin --pedimento ni --solo-malformados también procesa pedimentos bien formados."""
+ self._doc(f"vu_PT_{self.PED_APP}_1_REQUEST.xml", self.type_req, XML_ECO_REQUEST)
+
+ self._run()
+
+ self.partida.refresh_from_db()
+ self.assertFalse(self.partida.descargado)
+
+ def test_solo_malformados_excluye_pedimentos_validos(self):
+ """Con --solo-malformados un pedimento bien formado no se procesa."""
+ self._doc(f"vu_PT_{self.PED_APP}_1_REQUEST.xml", self.type_req, XML_ECO_REQUEST)
+
+ self._run(solo_malformados=True)
+
+ self.partida.refresh_from_db()
+ self.assertTrue(self.partida.descargado)
+
+ def test_no_confunde_partida_1_con_11(self):
+ """La asignación por nombre no debe mezclar partida 1 con partida 11."""
+ from api.customs.management.commands.fix_partidas_error import Command
+
+ docs = [
+ SimpleNamespace(id=1, document_type_id=1, archivo=SimpleNamespace(name=f"org/x/vu_PT_{self.PED_APP}_1.xml")),
+ SimpleNamespace(id=2, document_type_id=17, archivo=SimpleNamespace(name=f"org/x/vu_PT_{self.PED_APP}_1_REQUEST.xml")),
+ SimpleNamespace(id=3, document_type_id=1, archivo=SimpleNamespace(name=f"org/x/vu_PT_{self.PED_APP}_11.xml")),
+ SimpleNamespace(id=4, document_type_id=17, archivo=SimpleNamespace(name=f"org/x/vu_PT_{self.PED_APP}_11_REQUEST.xml")),
+ ]
+ cmd = Command()
+
+ ids_p1 = {d.id for d in cmd._docs_de_partida(docs, self.PED_APP, 1)}
+ ids_p11 = {d.id for d in cmd._docs_de_partida(docs, self.PED_APP, 11)}
+
+ self.assertEqual(ids_p1, {1, 2})
+ self.assertEqual(ids_p11, {3, 4})
diff --git a/api/customs/views.py b/api/customs/views.py
index 4b0d314..f0ca01d 100644
--- a/api/customs/views.py
+++ b/api/customs/views.py
@@ -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.
diff --git a/api/customs/views_auditor.py b/api/customs/views_auditor.py
index 68bb23d..26ba96d 100644
--- a/api/customs/views_auditor.py
+++ b/api/customs/views_auditor.py
@@ -2510,7 +2510,7 @@ def auditar_integridad_remesa_endpoint(request):
@swagger_auto_schema(
method='post',
- operation_description="Audita integridad de COVEs del XML de remesa para un pedimento específico",
+ operation_description="Audita integridad de COVEs del XML de remesa para un pedimento específico. Deduce si es consolidado desde el identificador PC del pedimento completo; si falta el documento de remesa, dispara la consulta a VUCEM",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={'pedimento_id': openapi.Schema(type=openapi.TYPE_STRING)},
diff --git a/api/organization/admin.py b/api/organization/admin.py
index b29af1a..d3d15f3 100644
--- a/api/organization/admin.py
+++ b/api/organization/admin.py
@@ -5,14 +5,18 @@ from .models import Organizacion
@admin.register(Organizacion)
class OrganizacionAdmin(admin.ModelAdmin):
- list_display = ('nombre', 'rfc', 'email', 'telefono', 'owner', 'is_active', 'is_verified', 'inicio', 'vencimiento')
- search_fields = ('nombre', 'rfc', 'email')
+ list_display = ('nombre', 'rfc', 'hub_tenant_slug', 'email', 'owner', 'is_active', 'is_verified', 'inicio', 'vencimiento')
+ search_fields = ('nombre', 'rfc', 'email', 'hub_tenant_slug')
list_filter = ('is_active', 'is_verified', 'is_agente_aduanal')
ordering = ('nombre',)
autocomplete_fields = ('owner',)
readonly_fields = ('created_at', 'updated_at')
fieldsets = (
(None, {'fields': ('nombre', 'rfc', 'titular', 'licencia')}),
+ ('Integración Hub', {
+ 'fields': ('hub_tenant_slug',),
+ 'description': 'Slug único del tenant en Aduanasoft Hub. Debe coincidir exactamente con el slug creado en el panel del Hub.',
+ }),
('Contacto', {'fields': ('email', 'telefono', 'estado', 'ciudad')}),
('Administrador maestro', {'fields': ('owner',)}),
('Estado', {'fields': ('is_active', 'is_verified', 'is_agente_aduanal', 'apply_auto_download')}),
diff --git a/api/organization/migrations/0005_organizacion_hub_tenant_slug.py b/api/organization/migrations/0005_organizacion_hub_tenant_slug.py
new file mode 100644
index 0000000..4cf3fb7
--- /dev/null
+++ b/api/organization/migrations/0005_organizacion_hub_tenant_slug.py
@@ -0,0 +1,16 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('organization', '0004_organizacion_owner'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='organizacion',
+ name='hub_tenant_slug',
+ field=models.CharField(blank=True, default='', max_length=100),
+ ),
+ ]
diff --git a/api/organization/models.py b/api/organization/models.py
index 0785f59..b6af30e 100644
--- a/api/organization/models.py
+++ b/api/organization/models.py
@@ -61,7 +61,10 @@ class Organizacion(models.Model):
updated_at = models.DateTimeField(auto_now=True)
observaciones = models.TextField(null=True, blank=True)
-
+
+ # Slug del tenant en Hub — "temex", "empresa-abc", etc.
+ hub_tenant_slug = models.CharField(max_length=100, blank=True, default='')
+
@property
def espacio_utilizado(self):
diff --git a/api/record/views.py b/api/record/views.py
index 8f301eb..6653847 100644
--- a/api/record/views.py
+++ b/api/record/views.py
@@ -43,6 +43,7 @@ from django.core.files.storage import default_storage
from django.conf import settings
import requests
import re
+import xml.etree.ElementTree as ET
from mixins.filtrado_organizacion import DocumentosFiltradosMixin
@@ -122,9 +123,69 @@ def obtener_tipo_documento_por_patron(nombre_archivo, organizacion, pedimento_id
raise ValidationError({
"error": f"El tipo de documento '{descripcion}' no existe. Por favor, créelo primero."
})
-
+
return None
+# Apartado "Pedimento" del detalle: los XML se clasifican por contenido (no por nombre de
+# archivo) usando los namespaces de las respuestas SOAP de VUCEM que deposita el microservicio,
+# y se renombran a la nomenclatura canónica vu_PC_/vu_RM_{pedimento_app}.xml (tipos 2 y 3,
+# los mismos que asigna el microservicio y que filtra PedimentoDocumentViewSet).
+NS_PEDIMENTO_COMPLETO = 'http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/consultarpedimentocompleto'
+NS_REMESAS = 'http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/consultarremesas'
+PEDIMENTO_TAB_TIPOS = {
+ NS_PEDIMENTO_COMPLETO: (2, 'vu_PC'),
+ NS_REMESAS: (3, 'vu_RM'),
+}
+
+
+def clasificar_xml_apartado_pedimento(file, pedimento):
+ """Clasifica un XML subido al apartado Pedimento como Pedimento Completo o Remesa.
+
+ Devuelve (document_type_id, nombre_canonico). Lanza ValueError con un mensaje
+ apto para el usuario si el archivo no es XML, no clasifica o pertenece a otro pedimento.
+ """
+ extension = file.name.split('.')[-1].lower() if '.' in file.name else ''
+ if extension != 'xml':
+ raise ValueError(f"'{file.name}': en este apartado solo se aceptan archivos XML")
+
+ try:
+ contenido = file.read()
+ file.seek(0)
+ root = ET.fromstring(contenido)
+ except ET.ParseError:
+ raise ValueError(f"'{file.name}': el archivo no es un XML válido")
+
+ tipo_encontrado = None
+ for elemento in root.iter():
+ for ns, mapeo in PEDIMENTO_TAB_TIPOS.items():
+ if isinstance(elemento.tag, str) and elemento.tag.startswith('{' + ns + '}'):
+ tipo_encontrado = (ns,) + mapeo
+ break
+ if tipo_encontrado:
+ break
+
+ if not tipo_encontrado:
+ raise ValueError(
+ f"'{file.name}': el XML no corresponde a un Pedimento Completo ni a una Remesa de VUCEM"
+ )
+
+ ns, type_id, prefijo = tipo_encontrado
+
+ # Validar pertenencia: el número de pedimento del XML debe coincidir con el actual.
+ # La respuesta de remesas no incluye el número, así que solo aplica a pedimento completo.
+ if ns == NS_PEDIMENTO_COMPLETO:
+ nodo = root.find(f'.//{{{ns}}}pedimento/{{{ns}}}pedimento')
+ numero_xml = re.sub(r'\D', '', nodo.text or '') if nodo is not None else ''
+ numero_actual = re.sub(r'\D', '', pedimento.pedimento or '')
+ if numero_xml and numero_actual and numero_xml != numero_actual:
+ raise ValueError(
+ f"'{file.name}': el XML corresponde al pedimento {nodo.text.strip()}, "
+ f"no al pedimento actual ({pedimento.pedimento_app})"
+ )
+
+ return type_id, f"{prefijo}_{pedimento.pedimento_app}.xml"
+
+
class CustomPagination(PageNumberPagination):
"""
@@ -191,7 +252,7 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
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','25','23','21','19','17','15','13','16'])
+ queryset = queryset.exclude(document_type_id__in=['1','2','3','4','5','6','7','8','9','10','25','23','21','19','17','15','13','14','16','18','20','22','24','26'])
# Filtro personalizado por document_type
# document_type = self.request.query_params.get('document_type')
# if document_type:
@@ -1133,6 +1194,10 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
defaults={'descripcion': "Documento general sin tipo específico"}
)
+ # Apartado del detalle desde el que se sube; 'pedimento' activa la
+ # clasificación del XML por contenido y el renombrado canónico
+ tab_seccion = request.data.get('tab_seccion')
+
uploaded_documents = []
failed_files = []
errors = []
@@ -1188,6 +1253,27 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
errors.append("Archivo sin nombre detectado")
continue
+ # Tipo por archivo: en el apartado Pedimento se clasifica el XML por
+ # contenido y se renombra a la nomenclatura canónica vu_PC_/vu_RM_
+ file_document_type = document_type
+ tipo_explicito = bool(document_type_id_param)
+ if tab_seccion == 'pedimento':
+ try:
+ type_id, nombre_canonico = clasificar_xml_apartado_pedimento(file, pedimento)
+ file_document_type = DocumentType.objects.get(id=type_id)
+ except ValueError as e:
+ failed_files.append(file.name)
+ errors.append(str(e))
+ continue
+ except DocumentType.DoesNotExist:
+ failed_files.append(file.name)
+ errors.append(
+ f"'{file.name}': el tipo de documento requerido no existe en el catálogo. Por favor, créelo primero."
+ )
+ continue
+ file.name = nombre_canonico
+ tipo_explicito = True
+
# Obtener extensión del archivo
extension = file.name.split('.')[-1].lower() if '.' in file.name else ''
@@ -1195,15 +1281,25 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
# 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:
+
+ # En el apartado Pedimento el reemplazo es por tipo: solo debe existir
+ # un Pedimento Completo y una Remesa por pedimento
+ if tab_seccion == 'pedimento':
+ for doc in existing_docs:
+ if doc.document_type_id == file_document_type.id:
existing_doc = doc
break
+ if existing_doc is 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:
@@ -1218,7 +1314,10 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
existing_doc.archivo = ruta
existing_doc.size = file.size
existing_doc.extension = extension
- existing_doc.document_type = document_type
+ # Conservar el tipo del documento existente salvo que el
+ # request lo defina explícitamente (no degradar docs VU)
+ if tipo_explicito:
+ existing_doc.document_type = file_document_type
existing_doc.save()
else:
raise Exception(f"Error al guardar archivo: {file.name}")
@@ -1230,7 +1329,7 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
document = Document.objects.create(
organizacion=organizacion,
pedimento_id=pedimento_id,
- document_type=document_type,
+ document_type=file_document_type,
size=file.size,
extension=extension
)
@@ -1248,6 +1347,8 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
raise Exception(f"Error al guardar archivo: {file.name}")
created_count += 1
was_replaced = False
+ # Visible para detección de duplicados de archivos posteriores del mismo lote
+ existing_docs.append(document)
# Actualizar espacio usado
espacio_usado_temp += file.size
@@ -1299,8 +1400,12 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
}
if failed_files:
+ if uploaded_documents:
+ mensaje_fallo = f"Algunos documentos no pudieron ser subidos. {mensaje_exito}"
+ else:
+ mensaje_fallo = "No fue posible subir ningún documento"
response_data.update({
- "message": f"Algunos documentos no pudieron ser subidos. {mensaje_exito}",
+ "message": mensaje_fallo,
"failed_files": failed_files,
"errors": errors,
})
@@ -1640,7 +1745,7 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
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
+ from api.customs.models import Pedimento as PedimentoModel, Partida, Cove, EDocument, EstadoDescarga
try:
pedimento = PedimentoModel.objects.get(id=pedimento_id)
except PedimentoModel.DoesNotExist:
@@ -1788,6 +1893,12 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
)
if ruta:
+ # Confirmar que el archivo quedó físicamente en storage antes
+ # de contar la sección como subida (T2026-05-027): nunca marcar
+ # descargado sin archivo verificado
+ if not storage_service.file_exists(ruta):
+ document.delete()
+ raise Exception(f"El archivo no se encuentra en storage tras guardarlo: {file.name}")
document.archivo = ruta
document.save()
else:
@@ -1811,7 +1922,8 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
errors.append(f"Error al procesar {file.name}: {str(e)}")
continue
- # Actualizar flags de descarga según secciones subidas exitosamente
+ # Actualizar estados de descarga según secciones subidas y verificadas
+ # en storage; el modelo deriva los booleanos legados del estado
if tab_seccion == 'partida':
if uploaded_secciones:
expediente_obj.descargado = True
@@ -1819,21 +1931,21 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
elif tab_seccion == 'cove':
update_fields = []
if 'general' in uploaded_secciones:
- expediente_obj.cove_descargado = True
- update_fields.append('cove_descargado')
+ expediente_obj.cove_estado = EstadoDescarga.DESCARGADO
+ update_fields.append('cove_estado')
if 'acuse' in uploaded_secciones:
- expediente_obj.acuse_cove_descargado = True
- update_fields.append('acuse_cove_descargado')
+ expediente_obj.acuse_cove_estado = EstadoDescarga.DESCARGADO
+ update_fields.append('acuse_cove_estado')
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')
+ expediente_obj.edocument_estado = EstadoDescarga.DESCARGADO
+ update_fields.append('edocument_estado')
if 'acuse' in uploaded_secciones:
- expediente_obj.acuse_descargado = True
- update_fields.append('acuse_descargado')
+ expediente_obj.acuse_estado = EstadoDescarga.DESCARGADO
+ update_fields.append('acuse_estado')
if update_fields:
expediente_obj.save(update_fields=update_fields)
diff --git a/api/reports/migrations/0004_alter_reportdocument_report_type.py b/api/reports/migrations/0004_alter_reportdocument_report_type.py
new file mode 100644
index 0000000..fda47e4
--- /dev/null
+++ b/api/reports/migrations/0004_alter_reportdocument_report_type.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.2.3 on 2026-06-11 14:42
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('reports', '0003_alter_reportdocument_file'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='reportdocument',
+ name='report_type',
+ field=models.CharField(choices=[('cumplimiento', 'cumplimiento'), ('control_pedimento', 'control_pedimento'), ('datastage', 'datastage')], default='cumplimiento', max_length=30),
+ ),
+ ]
diff --git a/api/reports/models.py b/api/reports/models.py
index 2ea1278..100ce53 100644
--- a/api/reports/models.py
+++ b/api/reports/models.py
@@ -12,6 +12,7 @@ class ReportDocument(models.Model):
TYPE_REPORT = [
('cumplimiento', 'cumplimiento'),
('control_pedimento', 'control_pedimento'),
+ ('datastage', 'datastage'),
]
user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, related_name='report_documents')
filters = models.JSONField(blank=True, null=True)
diff --git a/api/reports/services/__init__.py b/api/reports/services/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/api/reports/services/datastage_export.py b/api/reports/services/datastage_export.py
new file mode 100644
index 0000000..a237a8f
--- /dev/null
+++ b/api/reports/services/datastage_export.py
@@ -0,0 +1,557 @@
+"""
+Lógica de exportación de reportes DataStage, extraída de ExportDataStageView
+para poder ejecutarse dentro de una task Celery (sin request/HttpResponse).
+
+Cada builder devuelve una tupla (content_bytes, filename, content_type, total_rows).
+El aislamiento multi-tenant viene resuelto en global_filters['organizacion']
+(la vista lo resuelve con get_org_context antes de encolar).
+"""
+import csv
+import datetime
+import hashlib
+import io
+import uuid
+import zipfile
+
+import openpyxl
+from django.apps import apps
+from django.core.paginator import Paginator
+
+from api.organization.models import Organizacion
+
+MAX_RECORDS_PER_FILE = 500000 # Límite por archivo Excel antes de particionar en ZIP
+
+XLSX_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
+CSV_CONTENT_TYPE = 'text/csv; charset=utf-8'
+ZIP_CONTENT_TYPE = 'application/zip'
+
+RELATION_FIELDS = ['seccion_aduanera', 'patente', 'pedimento']
+
+
+def safe_excel_value(value):
+ """Convierte cualquier valor a un formato seguro para Excel/CSV."""
+ if value is None:
+ return ''
+ elif isinstance(value, (uuid.UUID,)):
+ return str(value)
+ elif hasattr(value, 'uuid'):
+ return str(value.uuid)
+ elif hasattr(value, 'id'):
+ return str(value.id)
+ elif isinstance(value, (datetime.datetime, datetime.date)):
+ return value.isoformat()
+ elif isinstance(value, (dict, list)):
+ return str(value)
+ else:
+ return str(value)
+
+
+def apply_global_filters_to_model(global_filters, model):
+ """Traduce los filtros globales a filtros ORM según los campos del modelo."""
+ filters = {}
+ model_fields = [f.name for f in model._meta.get_fields()]
+
+ # Organización — FK usa UUID, CharField usa el string tal cual
+ org_value = global_filters.get('organizacion')
+ if org_value and org_value != '' and 'organizacion' in model_fields:
+ field = model._meta.get_field('organizacion')
+ if hasattr(field, 'related_model'):
+ try:
+ filters['organizacion_id'] = uuid.UUID(org_value)
+ except Exception:
+ filters['organizacion_id'] = org_value
+ else:
+ filters['organizacion'] = org_value
+
+ rfc_value = global_filters.get('rfc')
+ if rfc_value and rfc_value != '' and 'rfc' in model_fields:
+ filters['rfc'] = rfc_value
+
+ if global_filters.get('patente'):
+ filters['patente'] = global_filters['patente']
+
+ if global_filters.get('pedimento'):
+ filters['pedimento'] = global_filters['pedimento']
+
+ if 'fecha_pago_real' in model_fields:
+ if global_filters.get('fecha_pago_desde'):
+ filters['fecha_pago_real__gte'] = global_filters['fecha_pago_desde']
+ if global_filters.get('fecha_pago_hasta'):
+ filters['fecha_pago_real__lte'] = global_filters['fecha_pago_hasta']
+
+ return filters
+
+
+def apply_related_filters(global_filters, model, related_keys):
+ """Filtros para modo múltiple: globales + llaves de cruce entre modelos."""
+ filters = {}
+ model_fields = [f.name for f in model._meta.get_fields()]
+
+ if 'organizacion' in model_fields and global_filters.get('organizacion'):
+ org_value = global_filters['organizacion']
+ try:
+ field = model._meta.get_field('organizacion')
+ if hasattr(field, 'related_model'):
+ filters['organizacion_id'] = uuid.UUID(org_value)
+ else:
+ filters['organizacion'] = org_value
+ except Exception:
+ filters['organizacion_id'] = org_value
+
+ if 'rfc' in model_fields and global_filters.get('rfc'):
+ filters['rfc'] = global_filters['rfc']
+
+ if 'fecha_pago_real' in model_fields:
+ if global_filters.get('fecha_pago_desde'):
+ filters['fecha_pago_real__gte'] = global_filters['fecha_pago_desde']
+ if global_filters.get('fecha_pago_hasta'):
+ filters['fecha_pago_real__lte'] = global_filters['fecha_pago_hasta']
+
+ if any(related_keys.values()):
+ if related_keys.get('patentes') and 'patente' in model_fields:
+ filters['patente__in'] = related_keys['patentes']
+ if related_keys.get('pedimentos') and 'pedimento' in model_fields:
+ filters['pedimento__in'] = related_keys['pedimentos']
+ if related_keys.get('datastage_ids') and 'datastage_id' in model_fields:
+ filters['datastage_id__in'] = related_keys['datastage_ids']
+ else:
+ if 'patente' in model_fields and global_filters.get('patente'):
+ filters['patente'] = global_filters['patente']
+ if 'pedimento' in model_fields and global_filters.get('pedimento'):
+ filters['pedimento'] = global_filters['pedimento']
+
+ return filters
+
+
+def get_related_keys_from_filters(global_filters, models_data):
+ """
+ Construye el conjunto de (patente, pedimento, datastage_id) que servirá como
+ llave de cruce entre modelos.
+
+ Regla clave: si el filtro RFC está activo, solo los modelos que tienen el campo
+ 'rfc' pueden contribuir a related_keys. Los modelos sin 'rfc' (ej. 505, 506)
+ no se usan como semilla — solo se filtrarán más tarde usando las claves ya
+ construidas, evitando que contaminen el resultado con pedimentos de otros RFC.
+ """
+ related_keys = {
+ 'patentes': set(),
+ 'pedimentos': set(),
+ 'datastage_ids': set()
+ }
+
+ # Sin filtros significativos → sin cruce
+ if not any(v for v in global_filters.values() if v not in [None, '']):
+ return {}
+
+ rfc_filter_active = bool(global_filters.get('rfc'))
+ date_filter_active = bool(global_filters.get('fecha_pago_desde') or global_filters.get('fecha_pago_hasta'))
+ all_records_with_filters = []
+
+ for model_data in models_data:
+ model_name = model_data.get('model')
+ try:
+ model = apps.get_model('datastage', model_name)
+ model_field_names = {f.name for f in model._meta.get_fields() if hasattr(f, 'name')}
+
+ # Un modelo puede ser semilla de related_keys SOLO si tiene campos
+ # para aplicar TODOS los filtros activos
+ if rfc_filter_active and 'rfc' not in model_field_names:
+ continue
+ if date_filter_active and 'fecha_pago_real' not in model_field_names:
+ continue
+
+ filters = apply_global_filters_to_model(global_filters, model)
+ if not filters:
+ continue
+
+ records = model.objects.filter(**filters).values('patente', 'pedimento', 'datastage_id')
+ all_records_with_filters.extend(list(records))
+
+ except LookupError:
+ continue
+
+ if not all_records_with_filters:
+ return {'patentes': set(), 'pedimentos': set(), 'datastage_ids': set()}
+
+ for record in all_records_with_filters:
+ if record.get('patente'):
+ related_keys['patentes'].add(record['patente'])
+ if record.get('pedimento'):
+ related_keys['pedimentos'].add(record['pedimento'])
+ if record.get('datastage_id'):
+ related_keys['datastage_ids'].add(record['datastage_id'])
+
+ return {k: list(v) for k, v in related_keys.items() if v}
+
+
+# ---------------------------------------------------------------------------
+# Exportación simple (un solo modelo)
+# ---------------------------------------------------------------------------
+
+def build_simple_export(model_name, fields, global_filters, export_format, progress_cb=None):
+ progress_cb = progress_cb or (lambda p, m: None)
+
+ try:
+ model = apps.get_model('datastage', model_name)
+ except LookupError:
+ raise ValueError(f'Modelo {model_name} no encontrado')
+
+ filters = apply_global_filters_to_model(global_filters, model)
+ queryset = model.objects.filter(**filters).values(*fields)
+ total_records = queryset.count()
+ progress_cb(20, f'{model_name}: {total_records} registros encontrados')
+
+ if export_format == 'excel':
+ if total_records > MAX_RECORDS_PER_FILE:
+ content, filename, content_type = _simple_excel_partitioned(model_name, fields, queryset, progress_cb)
+ else:
+ content, filename, content_type = _simple_excel(model_name, fields, queryset, progress_cb)
+ else:
+ # CSV no tiene límite de filas — siempre un solo archivo
+ content, filename, content_type = _simple_csv(model_name, fields, queryset, progress_cb)
+
+ return content, filename, content_type, total_records
+
+
+def _simple_excel(model_name, fields, queryset, progress_cb):
+ progress_cb(40, f'Escribiendo Excel de {model_name}...')
+ wb = openpyxl.Workbook()
+ ws = wb.active
+ ws.append(fields)
+ for row in queryset:
+ ws.append([safe_excel_value(row[field]) for field in fields])
+ progress_cb(88, 'Serializando archivo...')
+ output = io.BytesIO()
+ wb.save(output)
+ return output.getvalue(), f'{model_name}.xlsx', XLSX_CONTENT_TYPE
+
+
+def _simple_csv(model_name, fields, queryset, progress_cb):
+ progress_cb(40, f'Escribiendo CSV de {model_name}...')
+ buf = io.StringIO()
+ writer = csv.DictWriter(buf, fieldnames=fields)
+ writer.writeheader()
+ for row in queryset:
+ writer.writerow(row)
+ progress_cb(88, 'Serializando archivo...')
+ return buf.getvalue().encode('utf-8'), f'{model_name}.csv', CSV_CONTENT_TYPE
+
+
+def _simple_excel_partitioned(model_name, fields, queryset, progress_cb):
+ zip_buffer = io.BytesIO()
+ with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
+ paginator = Paginator(queryset, MAX_RECORDS_PER_FILE)
+ for page_num in paginator.page_range:
+ pct = 25 + int((page_num / paginator.num_pages) * 55)
+ progress_cb(pct, f'Particionando {model_name}: parte {page_num}/{paginator.num_pages}')
+ page = paginator.page(page_num)
+
+ wb = openpyxl.Workbook()
+ ws = wb.active
+ ws.title = f'Parte_{page_num}'[:31]
+ ws.append(fields)
+ for row in page.object_list:
+ ws.append([safe_excel_value(row[field]) for field in fields])
+
+ part_buffer = io.BytesIO()
+ wb.save(part_buffer)
+ zip_file.writestr(f'{model_name}_part{page_num}.xlsx', part_buffer.getvalue())
+
+ progress_cb(88, 'Serializando archivo...')
+ return zip_buffer.getvalue(), f'{model_name}_particionado.zip', ZIP_CONTENT_TYPE
+
+
+def _simple_csv_partitioned(model_name, fields, queryset, progress_cb):
+ zip_buffer = io.BytesIO()
+ with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
+ paginator = Paginator(queryset, MAX_RECORDS_PER_FILE)
+ for page_num in paginator.page_range:
+ pct = 25 + int((page_num / paginator.num_pages) * 55)
+ progress_cb(pct, f'Particionando {model_name}: parte {page_num}/{paginator.num_pages}')
+ page = paginator.page(page_num)
+
+ csv_buffer = io.StringIO()
+ writer = csv.writer(csv_buffer)
+ writer.writerow(fields)
+ for row in page.object_list:
+ writer.writerow([safe_excel_value(row[field]) for field in fields])
+
+ zip_file.writestr(f'{model_name}_part{page_num}.csv', csv_buffer.getvalue())
+
+ progress_cb(88, 'Serializando archivo...')
+ return zip_buffer.getvalue(), f'{model_name}_particionado.zip', ZIP_CONTENT_TYPE
+
+
+# ---------------------------------------------------------------------------
+# Exportación múltiple (varios modelos agrupados por llaves de cruce)
+# ---------------------------------------------------------------------------
+
+def _collect_multiple_data(models_data, global_filters, related_keys, progress_cb):
+ """
+ Recolecta y agrupa los registros de todos los modelos por la llave
+ seccion_aduanera + patente + pedimento. Mapea organizacion_id → nombre.
+ """
+ org_mapping = {str(org.id): org.nombre for org in Organizacion.objects.all()}
+ all_models_data = {}
+ total_models = len(models_data) or 1
+
+ for idx, model_data in enumerate(models_data):
+ model_name = model_data.get('model')
+ fields = model_data.get('fields', [])
+
+ if not model_name or not fields:
+ continue
+
+ # Normalizar campos: 'organizacion' → 'organizacion_id', sin duplicados
+ normalized_fields = []
+ for f in fields:
+ key = f.strip() if isinstance(f, str) else f
+ if isinstance(key, str) and key.lower() == 'organizacion':
+ if 'organizacion_id' not in normalized_fields:
+ normalized_fields.append('organizacion_id')
+ else:
+ if key not in normalized_fields:
+ normalized_fields.append(key)
+ fields = normalized_fields
+
+ for req_field in RELATION_FIELDS:
+ if req_field not in fields:
+ fields.append(req_field)
+
+ try:
+ model = apps.get_model('datastage', model_name)
+ model_field_names = [f.name for f in model._meta.get_fields() if hasattr(f, 'name')]
+ if 'organizacion_id' not in fields and 'organizacion_id' in model_field_names:
+ fields.append('organizacion_id')
+
+ filters = apply_related_filters(global_filters, model, related_keys)
+ queryset = model.objects.filter(**filters).values(*fields) if filters else model.objects.none()
+
+ count = queryset.count()
+ pct = 20 + int((idx / total_models) * 55)
+ progress_cb(pct, f'Modelo {idx + 1}/{total_models}: {model_name} ({count} registros)')
+ if count == 0:
+ continue
+
+ relation_fields = [fn for fn in RELATION_FIELDS if fn in fields]
+ if not relation_fields:
+ relation_fields = ['datastage_id'] if 'datastage_id' in fields else [fields[0]]
+
+ for record in queryset:
+ key_parts = [str(record[rf]) for rf in relation_fields if rf in record and record[rf] is not None]
+ if not key_parts:
+ key = hashlib.md5(str(sorted(record.items())).encode()).hexdigest()[:10]
+ else:
+ key = "_".join(key_parts)
+
+ processed_record = {}
+ for field_name, value in record.items():
+ if field_name == 'organizacion_id' and value:
+ org_id_str = str(value)
+ if org_id_str in org_mapping:
+ processed_value = org_mapping[org_id_str]
+ else:
+ try:
+ org = Organizacion.objects.filter(id=value).first()
+ processed_value = org.nombre if org else org_id_str
+ org_mapping[org_id_str] = processed_value
+ except Exception:
+ processed_value = org_id_str
+ else:
+ processed_value = value
+
+ if field_name in relation_fields:
+ prefixed_field_name = field_name
+ else:
+ prefixed_field_name = f"{model_name}_{field_name}"
+
+ if field_name == 'organizacion_id':
+ prefixed_field_name = prefixed_field_name.replace('organizacion_id', 'organizacion_nombre')
+
+ processed_record[prefixed_field_name] = safe_excel_value(processed_value)
+
+ if key not in all_models_data:
+ all_models_data[key] = {'relation_fields': {}, 'model_records': {}}
+
+ for rel_field in relation_fields:
+ if rel_field in record:
+ all_models_data[key]['relation_fields'][rel_field] = record[rel_field]
+
+ if model_name not in all_models_data[key]['model_records']:
+ all_models_data[key]['model_records'][model_name] = []
+
+ all_models_data[key]['model_records'][model_name].append(processed_record)
+
+ except LookupError:
+ continue
+
+ return all_models_data
+
+
+def _build_combined_rows(all_models_data):
+ """Construye filas combinadas — repite el último registro en lugar de dejar vacíos."""
+ combined_rows = []
+ for key, data in all_models_data.items():
+ relation_fields_data = data['relation_fields']
+ model_records = data['model_records']
+
+ max_records_per_key = max((len(recs) for recs in model_records.values()), default=1)
+
+ for i in range(max_records_per_key):
+ row_data = {}
+ for rel_field, rel_value in relation_fields_data.items():
+ row_data[rel_field] = safe_excel_value(rel_value)
+ for model_name, records in model_records.items():
+ # Usar posición i o el último registro disponible
+ record = records[i] if i < len(records) else records[-1]
+ for field_name, value in record.items():
+ row_data[field_name] = value
+ combined_rows.append(row_data)
+
+ return combined_rows
+
+
+def _ordered_fields(combined_rows):
+ """Encabezados: campos de relación primero, luego organización, luego el resto."""
+ all_fields_set = set()
+ for row in combined_rows:
+ all_fields_set.update(row.keys())
+
+ all_fields = []
+ for rel_field in RELATION_FIELDS:
+ if rel_field in all_fields_set:
+ all_fields.append(rel_field)
+ all_fields_set.discard(rel_field)
+
+ org_fields = sorted(f for f in all_fields_set if 'organizacion' in f.lower())
+ for org_field in org_fields:
+ all_fields.append(org_field)
+ all_fields_set.discard(org_field)
+
+ all_fields.extend(sorted(all_fields_set))
+ return all_fields
+
+
+def build_multiple_export(models_data, global_filters, export_format, progress_cb=None):
+ progress_cb = progress_cb or (lambda p, m: None)
+
+ progress_cb(15, 'Resolviendo llaves de cruce entre modelos...')
+ related_keys = get_related_keys_from_filters(global_filters, models_data)
+
+ all_models_data = _collect_multiple_data(models_data, global_filters, related_keys, progress_cb)
+
+ # Sin datos → archivo con mensaje, no error (el frontend espera un archivo)
+ if not all_models_data:
+ if export_format == 'excel':
+ wb = openpyxl.Workbook()
+ ws = wb.active
+ ws.title = "Sin datos"
+ ws.append(["No se encontraron datos para los filtros especificados"])
+ output = io.BytesIO()
+ wb.save(output)
+ return output.getvalue(), 'datastage_sin_datos.xlsx', XLSX_CONTENT_TYPE, 0
+ else:
+ buf = io.StringIO()
+ csv.writer(buf).writerow(['No se encontraron datos para los filtros especificados'])
+ return buf.getvalue().encode('utf-8'), 'datastage_sin_datos.csv', CSV_CONTENT_TYPE, 0
+
+ progress_cb(80, 'Combinando filas...')
+ combined_rows = _build_combined_rows(all_models_data)
+ all_fields = _ordered_fields(combined_rows)
+ total_rows = len(combined_rows)
+
+ if export_format == 'excel':
+ content, filename, content_type = _multiple_excel(combined_rows, all_fields, progress_cb)
+ else:
+ content, filename, content_type = _multiple_csv(combined_rows, all_fields, progress_cb)
+
+ return content, filename, content_type, total_rows
+
+
+def _multiple_excel(combined_rows, all_fields, progress_cb):
+ now_str = datetime.datetime.now().strftime('%d/%m/%Y %H:%M:%S')
+ title_row = ["Reporte Datastage"]
+ date_row = [f"Generado: {now_str}"]
+
+ def _write_sheet(ws, sheet_name, page_rows):
+ ws.title = sheet_name[:31]
+ ws.append(title_row)
+ ws.append(date_row)
+ ws.append([])
+ ws.append(all_fields)
+ for row_data in page_rows:
+ ws.append([row_data.get(field, '') for field in all_fields])
+ for column in ws.columns:
+ max_length = 0
+ col_letter = column[0].column_letter
+ for cell in column:
+ try:
+ if len(str(cell.value)) > max_length:
+ max_length = len(str(cell.value))
+ except Exception:
+ pass
+ ws.column_dimensions[col_letter].width = min(max_length + 2, 50)
+
+ # Excel directo si cabe en un archivo; ZIP solo si se necesita particionar
+ paginator = Paginator(combined_rows, MAX_RECORDS_PER_FILE)
+
+ if paginator.num_pages == 1:
+ progress_cb(88, 'Serializando archivo...')
+ wb = openpyxl.Workbook()
+ _write_sheet(wb.active, "Datastage", paginator.page(1).object_list)
+ output = io.BytesIO()
+ wb.save(output)
+ return output.getvalue(), 'datastage_reporte.xlsx', XLSX_CONTENT_TYPE
+
+ zip_buffer = io.BytesIO()
+ with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
+ for page_num in paginator.page_range:
+ progress_cb(80 + int((page_num / paginator.num_pages) * 8),
+ f'Particionando: parte {page_num}/{paginator.num_pages}')
+ page = paginator.page(page_num)
+ current_wb = openpyxl.Workbook()
+ _write_sheet(current_wb.active, f"Datastage_p{page_num}", page.object_list)
+ part_buffer = io.BytesIO()
+ current_wb.save(part_buffer)
+ zip_file.writestr(f"datastage_part{page_num}.xlsx", part_buffer.getvalue())
+
+ progress_cb(88, 'Serializando archivo...')
+ return zip_buffer.getvalue(), 'datastage_combinado.zip', ZIP_CONTENT_TYPE
+
+
+def _multiple_csv(combined_rows, all_fields, progress_cb):
+ progress_cb(88, 'Serializando archivo...')
+ buf = io.StringIO()
+ writer = csv.writer(buf)
+ writer.writerow(all_fields)
+ for row_data in combined_rows:
+ writer.writerow([row_data.get(field, '') for field in all_fields])
+ return buf.getvalue().encode('utf-8'), 'datastage_reporte.csv', CSV_CONTENT_TYPE
+
+
+# ---------------------------------------------------------------------------
+# Dispatcher
+# ---------------------------------------------------------------------------
+
+def build_datastage_export(payload, progress_cb=None):
+ """
+ Genera el reporte DataStage a partir del payload persistido en
+ ReportDocument.filters. Lanza ValueError si el payload es inválido.
+
+ Retorna (content_bytes, filename, content_type, total_rows).
+ """
+ modo = payload.get('modo', 'simple')
+ export_format = payload.get('format', 'csv')
+ global_filters = payload.get('globalFilters') or {}
+
+ if modo == 'multiple':
+ models_data = payload.get('models') or []
+ if not models_data:
+ raise ValueError('models es requerido para exportación múltiple')
+ return build_multiple_export(models_data, global_filters, export_format, progress_cb)
+
+ model_name = payload.get('model')
+ fields = payload.get('fields')
+ if not model_name or not fields:
+ raise ValueError('model y fields son requeridos para exportación simple')
+ return build_simple_export(model_name, fields, global_filters, export_format, progress_cb)
diff --git a/api/reports/tasks/__init__.py b/api/reports/tasks/__init__.py
new file mode 100644
index 0000000..6115e1e
--- /dev/null
+++ b/api/reports/tasks/__init__.py
@@ -0,0 +1,3 @@
+# Importa los módulos de tasks para que autodiscover_tasks() los registre en el worker
+from .report_document import generate_report_document, generate_report_control_pedimento
+from .report_datastage import generate_report_datastage
diff --git a/api/reports/tasks/report_datastage.py b/api/reports/tasks/report_datastage.py
new file mode 100644
index 0000000..888fb9b
--- /dev/null
+++ b/api/reports/tasks/report_datastage.py
@@ -0,0 +1,105 @@
+import logging
+import traceback
+
+from celery import shared_task
+from celery.exceptions import SoftTimeLimitExceeded
+from django.core.files.uploadedfile import SimpleUploadedFile
+from django.utils import timezone
+
+from api.reports.models import ReportDocument
+from api.reports.services.datastage_export import build_datastage_export
+from api.utils.storage_service import storage_service
+from core.redis_events import publish_task_event
+
+logger = logging.getLogger('api.reports.tasks')
+
+
+@shared_task(bind=True, queue='reports', soft_time_limit=1800, time_limit=1860)
+def generate_report_datastage(self, report_id):
+ task_id = self.request.id
+ report = None
+
+ def _fail(msg, exc=None):
+ """Marca el reporte como error, notifica al frontend y loguea. Sin re-raise."""
+ tb = traceback.format_exc() if exc else ''
+ full_msg = f"{msg}\n\n{tb}".strip() if tb else msg
+ logger.error('[reporte_datastage] report=%s FALLO: %s', report_id, full_msg)
+ if report:
+ report.status = 'error'
+ report.error_message = full_msg
+ report.finished_at = timezone.now()
+ report.save(update_fields=['status', 'error_message', 'finished_at'])
+ publish_task_event(task_id, 'failed', msg, progress=0)
+
+ # ── 1. Obtener reporte ────────────────────────────────────────────────────
+ try:
+ report = ReportDocument.objects.get(id=report_id)
+ except ReportDocument.DoesNotExist:
+ logger.error('[reporte_datastage] ReportDocument %s no existe', report_id)
+ publish_task_event(task_id, 'failed', f'Reporte {report_id} no encontrado', progress=0)
+ return
+
+ logger.info('[reporte_datastage] Iniciando report=%s user=%s', report_id, report.user_id)
+ report.status = 'processing'
+ report.save(update_fields=['status'])
+ publish_task_event(task_id, 'processing', 'Iniciando generación de reporte...', progress=5)
+
+ try:
+ # La organización ya viene resuelta en el payload (la vista la fija antes de encolar)
+ payload = report.filters or {}
+ org_id = payload.get('organizacion_id')
+
+ def _progress(pct, msg):
+ publish_task_event(task_id, 'processing', msg, progress=pct)
+
+ # ── 2. Generar archivo (xlsx / csv / zip según modo, formato y volumen) ──
+ content, filename, content_type, total_rows = build_datastage_export(payload, _progress)
+
+ # ── 3. Subir a almacenamiento ─────────────────────────────────────────
+ logger.info('[reporte_datastage] report=%s archivo=%s size=%.1fKB filas=%d',
+ report_id, filename, len(content) / 1024, total_rows)
+ publish_task_event(task_id, 'processing', 'Subiendo a almacenamiento...', progress=93)
+
+ final_name = f"datastage_{report.id}_{timezone.now().strftime('%Y%m%d%H%M%S')}_{filename}"
+ ruta = storage_service.save_report(
+ file=SimpleUploadedFile(
+ name=final_name,
+ content=content,
+ content_type=content_type,
+ ),
+ organizacion_id=org_id,
+ metadata={
+ 'report_id': str(report.id),
+ 'report_type': 'datastage',
+ 'user_id': str(report.user.id) if report.user else None,
+ },
+ )
+
+ if ruta:
+ logger.info('[reporte_datastage] report=%s guardado en storage=%s', report_id, ruta)
+ report.file = ruta
+ report.status = 'ready'
+ else:
+ _fail('Error al guardar el archivo en almacenamiento (storage retornó None)')
+ return
+
+ report.finished_at = timezone.now()
+ report.save(update_fields=['status', 'file', 'finished_at', 'error_message'])
+
+ resultado = {
+ 'report_id': str(report.id),
+ 'total_registros': total_rows,
+ 'archivo': final_name,
+ }
+ publish_task_event(task_id, 'completed', 'Reporte generado exitosamente.', progress=100, resultado=resultado)
+ logger.info('[reporte_datastage] report=%s COMPLETADO filas=%d', report_id, total_rows)
+ return resultado
+
+ except SoftTimeLimitExceeded:
+ _fail('El reporte tardó más de 30 minutos y fue cancelado. Intenta con filtros más acotados.')
+
+ except ValueError as exc:
+ _fail(str(exc))
+
+ except Exception as exc:
+ _fail(str(exc), exc=exc)
diff --git a/api/reports/tests_datastage.py b/api/reports/tests_datastage.py
new file mode 100644
index 0000000..ed0626d
--- /dev/null
+++ b/api/reports/tests_datastage.py
@@ -0,0 +1,348 @@
+"""
+Tests para el reporte DataStage asíncrono (Celery + SSE).
+
+Ejecución:
+ python manage.py test api.reports.tests_datastage
+"""
+import csv
+import io
+from unittest.mock import MagicMock, patch
+
+import openpyxl
+from django.apps import apps
+from django.contrib.auth import get_user_model
+from django.db import connection
+from django.test import TestCase
+from rest_framework.test import APIClient
+
+from api.licence.models import Licencia
+from api.organization.models import Organizacion
+from api.reports.models import ReportDocument
+from api.reports.tasks.report_datastage import generate_report_datastage
+
+User = get_user_model()
+
+FAKE_PATH = 'org_x/reports/datastage_test.xlsx'
+
+
+# ── fixtures ──────────────────────────────────────────────────────────────────
+
+def _ensure_registro_created_at():
+ """Las migraciones 0013/0014 de datastage agregan created_at solo en estado
+ (SeparateDatabaseAndState) porque la columna ya existía en la BD real; en la
+ BD de test hay que crearla explícitamente para poder insertar registros."""
+ with connection.cursor() as cur:
+ cur.execute('ALTER TABLE registro501 ADD COLUMN IF NOT EXISTS created_at timestamptz')
+ cur.execute('ALTER TABLE registro502 ADD COLUMN IF NOT EXISTS created_at timestamptz')
+
+
+def _org(nombre='Org DataStage'):
+ lic = Licencia.objects.create(nombre=f'Lic {nombre}', almacenamiento=10)
+ return Organizacion.objects.create(nombre=nombre, is_active=True, is_verified=True, licencia=lic)
+
+
+def _user(org, username='ds_user', superuser=False):
+ if superuser:
+ u = User.objects.create_superuser(username=username, password='pass', email=f'{username}@test.mx')
+ # Superuser JWT requiere active_organization (OrgScopedPermission)
+ u.active_organization = org
+ u.save(update_fields=['active_organization'])
+ return u
+ return User.objects.create_user(username=username, password='pass', organizacion=org)
+
+
+def _registro501(org, pedimento='1000001', rfc='XAXX010101000', patente='3910'):
+ Registro501 = apps.get_model('datastage', 'Registro501')
+ return Registro501.objects.create(
+ organizacion=org, patente=patente, pedimento=pedimento,
+ seccion_aduanera='160', rfc=rfc,
+ )
+
+
+def _registro502(org, pedimento='1000001', patente='3910', transportista='Transportes Test'):
+ Registro502 = apps.get_model('datastage', 'Registro502')
+ return Registro502.objects.create(
+ organizacion=org, patente=patente, pedimento=pedimento,
+ seccion_aduanera='160', nombre_transportista=transportista,
+ )
+
+
+def _reporte(user, payload):
+ return ReportDocument.objects.create(
+ user=user, filters=payload, status='pending', report_type='datastage'
+ )
+
+
+def _payload_simple(org, fmt='excel', model='Registro501', fields=None):
+ return {
+ 'modo': 'simple',
+ 'format': fmt,
+ 'globalFilters': {'organizacion': str(org.id)},
+ 'organizacion_id': str(org.id),
+ 'model': model,
+ 'fields': fields or ['patente', 'pedimento', 'rfc'],
+ }
+
+
+def _payload_multiple(org, fmt='excel'):
+ return {
+ 'modo': 'multiple',
+ 'format': fmt,
+ 'globalFilters': {'organizacion': str(org.id)},
+ 'organizacion_id': str(org.id),
+ 'models': [
+ {'model': 'Registro501', 'name': 'Datos generales', 'fields': ['rfc', 'patente', 'pedimento']},
+ {'model': 'Registro502', 'name': 'Transporte', 'fields': ['nombre_transportista']},
+ ],
+ }
+
+
+def _archivo_desde_mock(mock_save):
+ """Devuelve (nombre, bytes) del archivo que recibió storage_service.save_report."""
+ uf = mock_save.call_args[1]['file']
+ return uf.name, uf.read()
+
+
+# ── 1. Task Celery ────────────────────────────────────────────────────────────
+# Se mockean Redis (publish_task_event) y MinIO (storage_service.save_report).
+
+@patch('api.reports.tasks.report_datastage.publish_task_event')
+@patch('api.reports.tasks.report_datastage.storage_service.save_report',
+ return_value=FAKE_PATH)
+class TestGenerateReportDatastage(TestCase):
+ @classmethod
+ def setUpTestData(cls):
+ _ensure_registro_created_at()
+ cls.org = _org()
+ cls.user = _user(cls.org)
+
+ def _run(self, report):
+ generate_report_datastage.apply(args=[str(report.id)])
+ report.refresh_from_db()
+
+ # ── 1.1 Simple / Excel ────────────────────────────────────────────────────
+
+ def test_simple_excel_status_ready_y_archivo_xlsx(self, mock_save, mock_pub):
+ _registro501(self.org)
+ report = _reporte(self.user, _payload_simple(self.org, fmt='excel'))
+ self._run(report)
+
+ self.assertEqual(report.status, 'ready')
+ self.assertEqual(report.file, FAKE_PATH)
+ self.assertIsNotNone(report.finished_at)
+
+ nombre, contenido = _archivo_desde_mock(mock_save)
+ self.assertTrue(nombre.endswith('.xlsx'), f'Esperado .xlsx, recibido: {nombre}')
+ wb = openpyxl.load_workbook(io.BytesIO(contenido))
+ valores = [str(c.value) for row in wb.active.iter_rows() for c in row if c.value]
+ self.assertIn('XAXX010101000', valores)
+
+ # ── 1.2 Simple / CSV ──────────────────────────────────────────────────────
+
+ def test_simple_csv_status_ready_y_archivo_csv(self, mock_save, mock_pub):
+ _registro501(self.org)
+ report = _reporte(self.user, _payload_simple(self.org, fmt='csv'))
+ self._run(report)
+
+ self.assertEqual(report.status, 'ready')
+ nombre, contenido = _archivo_desde_mock(mock_save)
+ self.assertTrue(nombre.endswith('.csv'), f'Esperado .csv, recibido: {nombre}')
+
+ rows = list(csv.reader(io.StringIO(contenido.decode('utf-8'))))
+ self.assertEqual(rows[0], ['patente', 'pedimento', 'rfc'])
+ self.assertIn('XAXX010101000', rows[1])
+
+ # ── 1.3 Aislamiento por organización ──────────────────────────────────────
+
+ def test_simple_no_incluye_datos_de_otra_organizacion(self, mock_save, mock_pub):
+ _registro501(self.org, rfc='XAXX010101000')
+ otra_org = _org('Otra Org')
+ _registro501(otra_org, pedimento='9999999', rfc='XEXX010101000')
+
+ report = _reporte(self.user, _payload_simple(self.org, fmt='csv'))
+ self._run(report)
+
+ _, contenido = _archivo_desde_mock(mock_save)
+ texto = contenido.decode('utf-8')
+ self.assertIn('XAXX010101000', texto)
+ self.assertNotIn('XEXX010101000', texto)
+
+ # ── 1.4 Múltiple / Excel ──────────────────────────────────────────────────
+
+ def test_multiple_excel_combina_modelos_por_llave(self, mock_save, mock_pub):
+ _registro501(self.org)
+ _registro502(self.org)
+ report = _reporte(self.user, _payload_multiple(self.org, fmt='excel'))
+ self._run(report)
+
+ self.assertEqual(report.status, 'ready')
+ nombre, contenido = _archivo_desde_mock(mock_save)
+ self.assertTrue(nombre.endswith('datastage_reporte.xlsx'), f'Nombre inesperado: {nombre}')
+
+ wb = openpyxl.load_workbook(io.BytesIO(contenido))
+ valores = [str(c.value) for row in wb.active.iter_rows() for c in row if c.value]
+ # Campos de ambos modelos en la misma hoja (prefijados por modelo)
+ self.assertIn('Registro501_rfc', valores)
+ self.assertIn('Registro502_nombre_transportista', valores)
+ self.assertIn('XAXX010101000', valores)
+ self.assertIn('Transportes Test', valores)
+
+ # ── 1.5 Múltiple / CSV ────────────────────────────────────────────────────
+
+ def test_multiple_csv_combina_modelos(self, mock_save, mock_pub):
+ _registro501(self.org)
+ _registro502(self.org)
+ report = _reporte(self.user, _payload_multiple(self.org, fmt='csv'))
+ self._run(report)
+
+ self.assertEqual(report.status, 'ready')
+ nombre, contenido = _archivo_desde_mock(mock_save)
+ self.assertTrue(nombre.endswith('datastage_reporte.csv'), f'Nombre inesperado: {nombre}')
+ texto = contenido.decode('utf-8')
+ self.assertIn('Registro501_rfc', texto)
+ self.assertIn('Transportes Test', texto)
+
+ # ── 1.6 Sin datos ─────────────────────────────────────────────────────────
+
+ def test_multiple_sin_datos_genera_archivo_sin_datos_y_ready(self, mock_save, mock_pub):
+ report = _reporte(self.user, _payload_multiple(self.org, fmt='excel'))
+ self._run(report)
+
+ self.assertEqual(report.status, 'ready')
+ nombre, _ = _archivo_desde_mock(mock_save)
+ self.assertIn('sin_datos', nombre)
+
+ # ── 1.7 Payload inválido ──────────────────────────────────────────────────
+
+ def test_modelo_inexistente_marca_error_y_publica_failed(self, mock_save, mock_pub):
+ report = _reporte(self.user, _payload_simple(self.org, model='NoExiste'))
+ self._run(report)
+
+ self.assertEqual(report.status, 'error')
+ self.assertIn('NoExiste', report.error_message)
+ statuses = [c[0][1] for c in mock_pub.call_args_list]
+ self.assertIn('failed', statuses)
+ self.assertNotIn('completed', statuses)
+
+ def test_payload_sin_model_marca_error(self, mock_save, mock_pub):
+ payload = _payload_simple(self.org)
+ del payload['model']
+ report = _reporte(self.user, payload)
+ self._run(report)
+
+ self.assertEqual(report.status, 'error')
+ mock_save.assert_not_called()
+
+ # ── 1.8 Eventos de progreso ───────────────────────────────────────────────
+
+ def test_ultimo_evento_es_completed_con_100_y_resultado(self, mock_save, mock_pub):
+ _registro501(self.org)
+ report = _reporte(self.user, _payload_simple(self.org))
+ self._run(report)
+
+ ultimo = mock_pub.call_args_list[-1]
+ self.assertEqual(ultimo[0][1], 'completed')
+ self.assertEqual(ultimo[1].get('progress'), 100)
+ resultado = ultimo[1].get('resultado')
+ self.assertEqual(resultado['report_id'], str(report.id))
+ self.assertEqual(resultado['total_registros'], 1)
+
+ def test_se_publican_eventos_de_progreso(self, mock_save, mock_pub):
+ _registro501(self.org)
+ report = _reporte(self.user, _payload_simple(self.org))
+ self._run(report)
+
+ self.assertGreaterEqual(mock_pub.call_count, 4, 'Se esperan mínimo 4 eventos')
+
+ # ── 1.9 Storage falla ─────────────────────────────────────────────────────
+
+ def test_storage_none_deja_status_error_y_failed(self, mock_save, mock_pub):
+ mock_save.return_value = None
+ _registro501(self.org)
+ report = _reporte(self.user, _payload_simple(self.org))
+ self._run(report)
+
+ self.assertEqual(report.status, 'error')
+ self.assertIn('almacenamiento', report.error_message)
+ statuses = [c[0][1] for c in mock_pub.call_args_list]
+ self.assertIn('failed', statuses)
+
+
+# ── 2. Vista (encolado 202) ───────────────────────────────────────────────────
+
+class TestExportDataStageView202(TestCase):
+ @classmethod
+ def setUpTestData(cls):
+ cls.org = _org('Org Vista')
+ cls.user = _user(cls.org, username='vista_admin', superuser=True)
+
+ def setUp(self):
+ self.client = APIClient()
+ self.client.force_authenticate(user=self.user)
+
+ def _post(self, body):
+ with patch('api.reports.views.generate_report_datastage.delay',
+ return_value=MagicMock(id='fake-task-id')) as mock_delay:
+ res = self.client.post('/api/v1/reports/exportmodel/datastage/', body, format='json')
+ return res, mock_delay
+
+ def test_post_simple_responde_202_con_task_y_report(self):
+ body = {
+ 'modo': 'simple', 'format': 'excel',
+ 'globalFilters': {'organizacion': str(self.org.id)},
+ 'model': 'Registro501', 'fields': ['patente', 'pedimento'],
+ }
+ res, mock_delay = self._post(body)
+
+ self.assertEqual(res.status_code, 202)
+ self.assertEqual(res.data['task_id'], 'fake-task-id')
+ self.assertEqual(res.data['status'], 'pending')
+
+ report = ReportDocument.objects.get(id=res.data['report_id'])
+ self.assertEqual(report.report_type, 'datastage')
+ self.assertEqual(report.user_id, self.user.id)
+ self.assertEqual(report.filters['organizacion_id'], str(self.org.id))
+ mock_delay.assert_called_once_with(report.id)
+
+ def test_post_multiple_persiste_models_en_filters(self):
+ body = {
+ 'modo': 'multiple', 'format': 'csv',
+ 'globalFilters': {'organizacion': str(self.org.id)},
+ 'models': [{'model': 'Registro501', 'fields': ['rfc']}],
+ }
+ res, _ = self._post(body)
+
+ self.assertEqual(res.status_code, 202)
+ report = ReportDocument.objects.get(id=res.data['report_id'])
+ self.assertEqual(report.filters['modo'], 'multiple')
+ self.assertEqual(report.filters['models'][0]['model'], 'Registro501')
+
+ def test_post_simple_sin_fields_responde_400(self):
+ body = {
+ 'modo': 'simple', 'format': 'excel',
+ 'globalFilters': {'organizacion': str(self.org.id)},
+ 'model': 'Registro501',
+ }
+ res, mock_delay = self._post(body)
+ self.assertEqual(res.status_code, 400)
+ mock_delay.assert_not_called()
+ self.assertFalse(ReportDocument.objects.filter(report_type='datastage').exists())
+
+ def test_post_multiple_sin_models_responde_400(self):
+ body = {
+ 'modo': 'multiple', 'format': 'excel',
+ 'globalFilters': {'organizacion': str(self.org.id)},
+ }
+ res, mock_delay = self._post(body)
+ self.assertEqual(res.status_code, 400)
+ mock_delay.assert_not_called()
+
+ def test_post_modelo_inexistente_responde_404(self):
+ body = {
+ 'modo': 'simple', 'format': 'excel',
+ 'globalFilters': {'organizacion': str(self.org.id)},
+ 'model': 'NoExiste', 'fields': ['x'],
+ }
+ res, mock_delay = self._post(body)
+ self.assertEqual(res.status_code, 404)
+ mock_delay.assert_not_called()
diff --git a/api/reports/views.py b/api/reports/views.py
index 0d6e009..5704ea1 100644
--- a/api/reports/views.py
+++ b/api/reports/views.py
@@ -25,7 +25,10 @@ from core.permissions import (
require_permission,
user_has_permission,
)
+from .models import ReportDocument
from .serializers import ExportModelSerializer
+from .services import datastage_export
+from .tasks.report_datastage import generate_report_datastage
def export_model_to_csv(request, model_name, fields, module='datastage', filters=None):
model = apps.get_model(module, model_name)
@@ -90,28 +93,13 @@ class ExportDataStageView(APIView):
return [IsAuthenticated(), require_permission('reportes.view')()]
return [IsAuthenticated(), require_permission('reportes.export')()]
- # Constantes para partición
- # MAX_RECORDS_PER_FILE = 100 # Límite seguro por archivo
- MAX_RECORDS_PER_FILE = 120000 # Límite seguro por archivo
+ # La lógica de exportación vive en services/datastage_export.py (la usa la
+ # task Celery generate_report_datastage); estos delegados conservan la
+ # interfaz para los métodos legacy de esta clase.
+ MAX_RECORDS_PER_FILE = datastage_export.MAX_RECORDS_PER_FILE
def safe_excel_value(self, value):
- """
- Convierte cualquier valor a un formato seguro para Excel
- """
- if value is None:
- return ''
- elif isinstance(value, (uuid.UUID,)):
- return str(value)
- elif hasattr(value, 'uuid'):
- return str(value.uuid)
- elif hasattr(value, 'id'):
- return str(value.id)
- elif isinstance(value, (datetime.datetime, datetime.date)):
- return value.isoformat()
- elif isinstance(value, (dict, list)):
- return str(value)
- else:
- return str(value)
+ return datastage_export.safe_excel_value(value)
def get(self, request, *args, **kwargs):
"""Retorna RFCs distintos de Registro501 para la organización activa del usuario."""
@@ -134,19 +122,67 @@ class ExportDataStageView(APIView):
except LookupError:
return Response({'rfcs': []})
- @swagger_auto_schema(request_body=ExportModelSerializer, responses={200: 'Archivo generado (Excel o CSV)'})
+ @swagger_auto_schema(request_body=ExportModelSerializer, responses={202: 'Reporte encolado (Celery)'})
def post(self, request, *args, **kwargs):
"""
- Endpoint específico para exportación de DataStage con soporte múltiple
+ Encola la generación asíncrona del reporte DataStage (Celery + SSE).
+ Responde 202 con report_id y task_id; el progreso se sigue por SSE
+ (/stream/tasks/{task_id}) y el archivo se descarga después vía
+ /reports/report-document-download/{report_id}/.
"""
- # Verificar si es modo múltiple
modo = request.data.get('modo', 'simple')
-
+ export_format = request.data.get('format', 'csv')
+ global_filters = request.data.get('globalFilters', {})
+
+ # Validar payload antes de encolar (mismos errores que el flujo síncrono)
if modo == 'multiple':
- return self.handle_multiple_export(request)
+ models_data = request.data.get('models', [])
+ if not models_data:
+ return Response({'error': 'models are required for multiple export'}, status=status.HTTP_400_BAD_REQUEST)
else:
- return self.handle_simple_export(request)
-
+ model_name = request.data.get('model')
+ fields = request.data.get('fields')
+ if not model_name or not fields:
+ return Response({'error': 'model and fields are required'}, status=status.HTTP_400_BAD_REQUEST)
+ try:
+ apps.get_model('datastage', model_name)
+ except LookupError:
+ return Response({'error': f'Model {model_name} not found'}, status=status.HTTP_404_NOT_FOUND)
+
+ global_filters, err = self._resolve_org_filter(global_filters, request.user)
+ if err:
+ return err
+
+ # La org ya resuelta viaja en el payload: la task no tiene request.user
+ payload = {
+ 'modo': modo,
+ 'format': export_format,
+ 'globalFilters': global_filters,
+ 'organizacion_id': global_filters.get('organizacion'),
+ }
+ if modo == 'multiple':
+ payload['models'] = models_data
+ else:
+ payload['model'] = model_name
+ payload['fields'] = fields
+
+ report = ReportDocument.objects.create(
+ user=request.user,
+ filters=payload,
+ status='pending',
+ report_type='datastage',
+ )
+ task = generate_report_datastage.delay(report.id)
+
+ return Response({
+ 'report_id': report.id,
+ 'task_id': task.id,
+ 'status': report.status,
+ 'created_at': report.created_at,
+ 'download_url': None,
+ }, status=status.HTTP_202_ACCEPTED)
+
+
def _resolve_org_filter(self, global_filters, user):
"""
Devuelve los global_filters asegurando que siempre haya una organización.
@@ -164,63 +200,6 @@ class ExportDataStageView(APIView):
filters['organizacion'] = str(org.id)
return filters, None
- def handle_simple_export(self, request):
- """Maneja exportación simple de DataStage (un solo modelo)"""
- model_name = request.data.get('model')
- fields = request.data.get('fields')
- global_filters = request.data.get('globalFilters', {})
- export_type = request.data.get('format', 'csv')
- module = 'datastage'
-
- if not model_name or not fields:
- return Response({'error': 'model and fields are required'}, status=status.HTTP_400_BAD_REQUEST)
-
- global_filters, err = self._resolve_org_filter(global_filters, request.user)
- if err:
- return err
-
- try:
- model = apps.get_model(module, model_name)
- filters = self.apply_global_filters_to_model(global_filters, model, request.user)
-
- queryset = model.objects.filter(**filters).values(*fields)
- total_records = queryset.count()
-
- if export_type == 'excel':
- # Verificar si necesita partición
- if total_records > self.MAX_RECORDS_PER_FILE:
- return self.export_single_model_partitioned(request, model_name, fields, filters, total_records)
- else:
- return export_model_to_excel(request, model_name, fields, module, filters)
- else:
- if total_records > self.MAX_RECORDS_PER_FILE:
- return self.export_single_model_csv_partitioned(request, model_name, fields, filters, total_records)
- else:
- return export_model_to_csv(request, model_name, fields, module, filters)
-
- except LookupError:
- return Response({'error': f'Model {model_name} not found'}, status=status.HTTP_404_NOT_FOUND)
-
- def handle_multiple_export(self, request):
- """Maneja exportación múltiple de DataStage (varios modelos)"""
- models_data = request.data.get('models', [])
- export_type = request.data.get('format', 'csv')
- global_filters = request.data.get('globalFilters', {})
-
- if not models_data:
- return Response({'error': 'models are required for multiple export'}, status=status.HTTP_400_BAD_REQUEST)
-
- global_filters, err = self._resolve_org_filter(global_filters, request.user)
- if err:
- return err
-
- related_keys = self.get_related_keys_from_filters(global_filters, models_data, request.user)
-
- if export_type == 'excel':
- return self.export_datastage_multiple_partitioned_excel_agrupados(request, models_data, global_filters, related_keys)
- else:
- return self.export_datastage_multiple_to_csv_combined(request, models_data, global_filters, related_keys)
-
def estimate_total_records(self, models_data, global_filters, related_keys, user):
"""Estima el total de registros para todos los modelos"""
total = 0
@@ -297,235 +276,6 @@ class ExportDataStageView(APIView):
response['Content-Disposition'] = 'attachment; filename="datastage_related_report.xlsx"'
return response
- def export_datastage_multiple_partitioned_excel_agrupados(self, request, models_data, global_filters, related_keys):
- """Exporta múltiples modelos de DataStage agrupados en la misma hoja de Excel, con particionado por límite de registros"""
- try:
- from api.organization.models import Organizacion
- org_mapping = {str(org.id): org.nombre for org in Organizacion.objects.all()}
-
- # 1. Recopilar todos los datos FUERA del contexto ZIP
- all_models_data = {}
- model_field_mappings = {}
-
- for model_data in models_data:
- model_name = model_data.get('model')
- fields = model_data.get('fields', [])
-
- if not model_name or not fields:
- continue
-
- normalized_fields = []
- for f in fields:
- try:
- key = f.strip() if isinstance(f, str) else f
- except Exception:
- key = f
-
- if isinstance(key, str) and key.lower() == 'organizacion':
- if 'organizacion_id' not in normalized_fields:
- normalized_fields.append('organizacion_id')
- else:
- if key not in normalized_fields:
- normalized_fields.append(key)
-
- fields = normalized_fields
-
- required_fields = ['seccion_aduanera', 'patente', 'pedimento']
- for field in required_fields:
- if field not in fields:
- fields.append(field)
-
- if 'organizacion_id' not in fields and 'organizacion_id' in [f.name for f in apps.get_model('datastage', model_name)._meta.get_fields()]:
- fields.append('organizacion_id')
-
- try:
- model = apps.get_model('datastage', model_name)
- filters = self.apply_related_filters(global_filters, model, related_keys, request.user)
-
- if filters:
- queryset = model.objects.filter(**filters).values(*fields)
- else:
- queryset = model.objects.none()
-
- if queryset.count() == 0:
- continue
-
- relation_fields = [fn for fn in ['seccion_aduanera', 'patente', 'pedimento'] if fn in fields]
- if not relation_fields:
- relation_fields = ['datastage_id'] if 'datastage_id' in fields else [fields[0]]
-
- if model_name not in model_field_mappings:
- model_field_mappings[model_name] = fields
-
- for record in queryset:
- key_parts = [str(record[rf]) for rf in relation_fields if rf in record and record[rf] is not None]
- if not key_parts:
- import hashlib
- key = hashlib.md5(str(sorted(record.items())).encode()).hexdigest()[:10]
- else:
- key = "_".join(key_parts)
-
- processed_record = {}
- for field_name, value in record.items():
- if field_name == 'organizacion_id' and value:
- org_id_str = str(value)
- if org_id_str in org_mapping:
- processed_value = org_mapping[org_id_str]
- else:
- try:
- org = Organizacion.objects.filter(id=value).first()
- processed_value = org.nombre if org else org_id_str
- org_mapping[org_id_str] = processed_value
- except Exception:
- processed_value = org_id_str
- else:
- processed_value = value
-
- if field_name in relation_fields:
- prefixed_field_name = field_name
- else:
- prefixed_field_name = f"{model_name}_{field_name}"
-
- if field_name == 'organizacion_id':
- prefixed_field_name = prefixed_field_name.replace('organizacion_id', 'organizacion_nombre')
-
- processed_record[prefixed_field_name] = self.safe_excel_value(processed_value)
-
- if key not in all_models_data:
- all_models_data[key] = {'relation_fields': {}, 'model_records': {}}
-
- for rel_field in relation_fields:
- if rel_field in record:
- all_models_data[key]['relation_fields'][rel_field] = record[rel_field]
-
- if model_name not in all_models_data[key]['model_records']:
- all_models_data[key]['model_records'][model_name] = []
-
- all_models_data[key]['model_records'][model_name].append(processed_record)
-
- except LookupError:
- continue
-
- # 2. Sin datos → Excel vacío (no JSON 404 que rompe la descarga en el frontend)
- if not all_models_data:
- wb = openpyxl.Workbook()
- ws = wb.active
- ws.title = "Sin datos"
- ws.append(["No se encontraron datos para los filtros especificados"])
- output = io.BytesIO()
- wb.save(output)
- output.seek(0)
- resp = HttpResponse(
- output.read(),
- content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
- )
- resp['Content-Disposition'] = 'attachment; filename="datastage_sin_datos.xlsx"'
- return resp
-
- # 3. Construir filas combinadas — repetir el último registro en lugar de dejar vacíos
- combined_rows = []
- for key, data in all_models_data.items():
- relation_fields_data = data['relation_fields']
- model_records = data['model_records']
-
- max_records_per_key = max((len(recs) for recs in model_records.values()), default=1)
-
- for i in range(max_records_per_key):
- row_data = {}
-
- for rel_field, rel_value in relation_fields_data.items():
- row_data[rel_field] = self.safe_excel_value(rel_value)
-
- for model_name, records in model_records.items():
- # Usar posición i o el último registro disponible
- record = records[i] if i < len(records) else records[-1]
- for field_name, value in record.items():
- row_data[field_name] = value
-
- combined_rows.append(row_data)
-
- # 4. Encabezados ordenados
- all_fields_set = set()
- for row in combined_rows:
- all_fields_set.update(row.keys())
-
- all_fields = []
- for rel_field in ['seccion_aduanera', 'patente', 'pedimento']:
- if rel_field in all_fields_set:
- all_fields.append(rel_field)
- all_fields_set.discard(rel_field)
-
- org_fields = sorted(f for f in all_fields_set if 'organizacion' in f.lower())
- for org_field in org_fields:
- all_fields.append(org_field)
- all_fields_set.discard(org_field)
-
- all_fields.extend(sorted(all_fields_set))
-
- # 5. Filas de título y fecha de generación
- now_str = datetime.datetime.now().strftime('%d/%m/%Y %H:%M:%S')
- title_row = ["Reporte Datastage"]
- date_row = [f"Generado: {now_str}"]
-
- def _write_sheet(ws, sheet_name, page_rows):
- ws.title = sheet_name[:31]
- ws.append(title_row)
- ws.append(date_row)
- ws.append([])
- ws.append(all_fields)
- for row_data in page_rows:
- ws.append([row_data.get(field, '') for field in all_fields])
- for column in ws.columns:
- max_length = 0
- col_letter = column[0].column_letter
- for cell in column:
- try:
- if len(str(cell.value)) > max_length:
- max_length = len(str(cell.value))
- except Exception:
- pass
- ws.column_dimensions[col_letter].width = min(max_length + 2, 50)
-
- # 6. Excel directo si cabe en un archivo; ZIP solo si se necesita particionar
- from django.core.paginator import Paginator
- paginator = Paginator(combined_rows, self.MAX_RECORDS_PER_FILE)
-
- if paginator.num_pages == 1:
- wb = openpyxl.Workbook()
- _write_sheet(wb.active, "Datastage", paginator.page(1).object_list)
- output = io.BytesIO()
- wb.save(output)
- output.seek(0)
- resp = HttpResponse(
- output.read(),
- content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
- )
- resp['Content-Disposition'] = 'attachment; filename="datastage_reporte.xlsx"'
- return resp
-
- zip_buffer = io.BytesIO()
- with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
- for page_num in paginator.page_range:
- page = paginator.page(page_num)
- current_wb = openpyxl.Workbook()
- _write_sheet(current_wb.active, f"Datastage_p{page_num}", page.object_list)
- part_buffer = io.BytesIO()
- current_wb.save(part_buffer)
- part_buffer.seek(0)
- zip_file.writestr(f"datastage_part{page_num}.xlsx", part_buffer.getvalue())
-
- zip_buffer.seek(0)
- resp = HttpResponse(zip_buffer.read(), content_type='application/zip')
- resp['Content-Disposition'] = 'attachment; filename="datastage_combinado.zip"'
- return resp
-
- except Exception as e:
- import traceback
- import logging
- logging.getLogger(__name__).error("Error en exportación combinada: %s", traceback.format_exc())
- return Response({'error': f'Error en exportación combinada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
-
-
def export_datastage_multiple_partitioned_excel_test_3(self, request, models_data, global_filters, related_keys):
"""Exporta múltiples modelos de DataStage agrupados en la misma hoja de Excel, con particionado por límite de registros"""
try:
@@ -1215,144 +965,6 @@ class ExportDataStageView(APIView):
except Exception as e:
return Response({'error': f'Error en exportación particionada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
- def export_datastage_multiple_to_csv_combined(self, request, models_data, global_filters, related_keys):
- """Exporta múltiples modelos combinados en un único CSV plano (misma lógica de agrupación que el Excel)."""
- import hashlib
- import logging
- import traceback
- logger = logging.getLogger(__name__)
- try:
- from api.organization.models import Organizacion
- org_mapping = {str(org.id): org.nombre for org in Organizacion.objects.all()}
-
- all_models_data = {}
- model_field_mappings = {}
-
- for model_data in models_data:
- model_name = model_data.get('model')
- fields = model_data.get('fields', [])
- if not model_name or not fields:
- continue
-
- normalized_fields = []
- for f in fields:
- key = f.strip() if isinstance(f, str) else f
- if isinstance(key, str) and key.lower() == 'organizacion':
- if 'organizacion_id' not in normalized_fields:
- normalized_fields.append('organizacion_id')
- else:
- if key not in normalized_fields:
- normalized_fields.append(key)
- fields = normalized_fields
-
- for req_field in ['seccion_aduanera', 'patente', 'pedimento']:
- if req_field not in fields:
- fields.append(req_field)
-
- try:
- model = apps.get_model('datastage', model_name)
- model_field_names = [f.name for f in model._meta.get_fields() if hasattr(f, 'name')]
- if 'organizacion_id' not in fields and 'organizacion_id' in model_field_names:
- fields.append('organizacion_id')
-
- filters = self.apply_related_filters(global_filters, model, related_keys, request.user)
- queryset = model.objects.filter(**filters).values(*fields) if filters else model.objects.none()
- if queryset.count() == 0:
- continue
-
- relation_fields = [fn for fn in ['seccion_aduanera', 'patente', 'pedimento'] if fn in fields]
- if not relation_fields:
- relation_fields = ['datastage_id'] if 'datastage_id' in fields else [fields[0]]
-
- if model_name not in model_field_mappings:
- model_field_mappings[model_name] = fields
-
- for record in queryset:
- key_parts = [str(record[rf]) for rf in relation_fields if rf in record and record[rf] is not None]
- key = "_".join(key_parts) if key_parts else hashlib.md5(str(sorted(record.items())).encode()).hexdigest()[:10]
-
- processed_record = {}
- for field_name, value in record.items():
- if field_name == 'organizacion_id' and value:
- org_id_str = str(value)
- processed_value = org_mapping.get(org_id_str, org_id_str)
- else:
- processed_value = value
-
- if field_name in relation_fields:
- prefixed = field_name
- else:
- prefixed = f"{model_name}_{field_name}"
- if field_name == 'organizacion_id':
- prefixed = prefixed.replace('organizacion_id', 'organizacion_nombre')
- processed_record[prefixed] = self.safe_excel_value(processed_value)
-
- if key not in all_models_data:
- all_models_data[key] = {'relation_fields': {}, 'model_records': {}}
- for rel_field in relation_fields:
- if rel_field in record:
- all_models_data[key]['relation_fields'][rel_field] = record[rel_field]
- if model_name not in all_models_data[key]['model_records']:
- all_models_data[key]['model_records'][model_name] = []
- all_models_data[key]['model_records'][model_name].append(processed_record)
-
- except LookupError:
- continue
-
- # Sin datos → CSV con mensaje, no error HTTP
- if not all_models_data:
- buf = io.StringIO()
- csv.writer(buf).writerow(['No se encontraron datos para los filtros especificados'])
- resp = HttpResponse(buf.getvalue(), content_type='text/csv; charset=utf-8')
- resp['Content-Disposition'] = 'attachment; filename="datastage_sin_datos.csv"'
- return resp
-
- # Construir filas planas
- combined_rows = []
- for key, data in all_models_data.items():
- relation_fields_data = data['relation_fields']
- model_records = data['model_records']
- max_records = max((len(recs) for recs in model_records.values()), default=1)
- for i in range(max_records):
- row_data = {}
- for rel_field, rel_value in relation_fields_data.items():
- row_data[rel_field] = self.safe_excel_value(rel_value)
- for mn, records in model_records.items():
- record = records[i] if i < len(records) else records[-1]
- for field_name, value in record.items():
- row_data[field_name] = value
- combined_rows.append(row_data)
-
- # Encabezados: campos de relación primero, luego org, luego el resto
- all_fields_set = set()
- for row in combined_rows:
- all_fields_set.update(row.keys())
-
- all_fields = []
- for rel_field in ['seccion_aduanera', 'patente', 'pedimento']:
- if rel_field in all_fields_set:
- all_fields.append(rel_field)
- all_fields_set.discard(rel_field)
- org_fields = sorted(f for f in all_fields_set if 'organizacion' in f.lower())
- for org_field in org_fields:
- all_fields.append(org_field)
- all_fields_set.discard(org_field)
- all_fields.extend(sorted(all_fields_set))
-
- buf = io.StringIO()
- writer = csv.writer(buf)
- writer.writerow(all_fields)
- for row_data in combined_rows:
- writer.writerow([row_data.get(field, '') for field in all_fields])
-
- resp = HttpResponse(buf.getvalue(), content_type='text/csv; charset=utf-8')
- resp['Content-Disposition'] = 'attachment; filename="datastage_reporte.csv"'
- return resp
-
- except Exception as e:
- logger.error("Error en exportación CSV combinada: %s", traceback.format_exc())
- return Response({'error': f'Error en exportación CSV combinada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
-
def export_datastage_multiple_to_csv(self, request, models_data, global_filters, related_keys):
"""Exporta múltiples modelos de DataStage a múltiples archivos CSV en ZIP"""
zip_buffer = io.BytesIO()
@@ -1472,254 +1084,14 @@ class ExportDataStageView(APIView):
except Exception as e:
return Response({'error': f'Error en exportación CSV particionada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
- def export_single_model_partitioned(self, request, model_name, fields, filters, total_records):
- """Exporta un solo modelo particionado a ZIP"""
- try:
- zip_buffer = io.BytesIO()
- module = 'datastage'
-
- model = apps.get_model(module, model_name)
- queryset = model.objects.filter(**filters).values(*fields)
-
- with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
- from django.core.paginator import Paginator
- paginator = Paginator(queryset, self.MAX_RECORDS_PER_FILE)
-
- for page_num in paginator.page_range:
- page = paginator.page(page_num)
-
- # Crear Excel para esta parte
- wb = openpyxl.Workbook()
- ws = wb.active
- ws.title = f"Parte_{page_num}"[:31]
- ws.append(fields)
-
- for row in page.object_list:
- row_values = [self.safe_excel_value(row[field]) for field in fields]
- ws.append(row_values)
-
- part_buffer = io.BytesIO()
- wb.save(part_buffer)
- part_buffer.seek(0)
-
- filename = f"{model_name}_part{page_num}.xlsx"
- zip_file.writestr(filename, part_buffer.getvalue())
-
- zip_buffer.seek(0)
- zip_content = zip_buffer.getvalue()
-
- response = HttpResponse(zip_content, content_type='application/zip')
- response['Content-Disposition'] = f'attachment; filename="{model_name}_particionado.zip"'
- response['Content-Length'] = len(zip_content)
-
- return response
-
- except Exception as e:
- return Response({'error': f'Error exportando modelo: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
-
- def export_single_model_csv_partitioned(self, request, model_name, fields, filters, total_records):
- """Exporta un solo modelo CSV particionado a ZIP"""
- try:
- zip_buffer = io.BytesIO()
- module = 'datastage'
-
- model = apps.get_model(module, model_name)
- queryset = model.objects.filter(**filters).values(*fields)
-
- with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
- from django.core.paginator import Paginator
- paginator = Paginator(queryset, self.MAX_RECORDS_PER_FILE)
-
- for page_num in paginator.page_range:
- page = paginator.page(page_num)
-
- csv_buffer = io.StringIO()
- writer = csv.writer(csv_buffer)
- writer.writerow(fields)
-
- for row in page.object_list:
- row_values = [self.safe_excel_value(row[field]) for field in fields]
- writer.writerow(row_values)
-
- # Agregar al ZIP
- filename = f"{model_name}_part{page_num}.csv"
- zip_file.writestr(filename, csv_buffer.getvalue())
-
- zip_buffer.seek(0)
-
- zip_content = zip_buffer.getvalue()
-
- response = HttpResponse(zip_content, content_type='application/zip')
- response['Content-Disposition'] = f'attachment; filename="{model_name}_particionado.zip"'
- response['Content-Length'] = len(zip_content)
-
- return response
-
- except Exception as e:
- return Response({'error': f'Error exportando modelo CSV: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
-
def get_related_keys_from_filters(self, global_filters, models_data, user):
- """
- Construye el conjunto de (patente, pedimento, datastage_id) que servirá como
- llave de cruce entre modelos.
+ return datastage_export.get_related_keys_from_filters(global_filters, models_data)
- Regla clave: si el filtro RFC está activo, solo los modelos que tienen el campo
- 'rfc' pueden contribuir a related_keys. Los modelos sin 'rfc' (ej. 505, 506)
- no se usan como semilla — solo se filtrarán más tarde usando las claves ya
- construidas, evitando que contaminen el resultado con pedimentos de otros RFC.
- """
- related_keys = {
- 'patentes': set(),
- 'pedimentos': set(),
- 'datastage_ids': set()
- }
-
- # Sin filtros significativos → sin cruce
- if not any(v for v in global_filters.values() if v not in [None, '']):
- return {}
-
- rfc_filter_active = bool(global_filters.get('rfc'))
- date_filter_active = bool(global_filters.get('fecha_pago_desde') or global_filters.get('fecha_pago_hasta'))
- all_records_with_filters = []
-
- for model_data in models_data:
- model_name = model_data.get('model')
- try:
- model = apps.get_model('datastage', model_name)
- model_field_names = {f.name for f in model._meta.get_fields() if hasattr(f, 'name')}
-
- # Un modelo puede ser semilla de related_keys SOLO si tiene campos
- # para aplicar TODOS los filtros activos. Un modelo sin 'rfc' no puede
- # ser semilla cuando hay filtro de RFC (contaminaría con pedimentos de
- # otros RFCs). Igual para fecha_pago_real cuando hay filtro de fechas.
- if rfc_filter_active and 'rfc' not in model_field_names:
- continue
- if date_filter_active and 'fecha_pago_real' not in model_field_names:
- continue
-
- filters = self.apply_global_filters_to_model(global_filters, model, user)
- if not filters:
- continue
-
- records = model.objects.filter(**filters).values('patente', 'pedimento', 'datastage_id')
- all_records_with_filters.extend(list(records))
-
- except LookupError:
- continue
-
- if not all_records_with_filters:
- return {'patentes': set(), 'pedimentos': set(), 'datastage_ids': set()}
-
- for record in all_records_with_filters:
- if record.get('patente'):
- related_keys['patentes'].add(record['patente'])
- if record.get('pedimento'):
- related_keys['pedimentos'].add(record['pedimento'])
- if record.get('datastage_id'):
- related_keys['datastage_ids'].add(record['datastage_id'])
-
- return {k: list(v) for k, v in related_keys.items() if v}
-
def apply_global_filters_to_model(self, global_filters, model, user):
- """
- Aplica filtros globales - VERSIÓN CORREGIDA CON UUID
- """
-
- filters = {}
- model_fields = [f.name for f in model._meta.get_fields()]
-
- # ORGANIZACIÓN - Manejar como UUID
- org_value = global_filters.get('organizacion')
- if org_value and org_value != '' and 'organizacion' in model_fields:
- field = model._meta.get_field('organizacion')
-
- if hasattr(field, 'related_model'): # Es ForeignKey
- # Convertir string a UUID
- try:
- import uuid
- org_uuid = uuid.UUID(org_value)
- filters['organizacion_id'] = org_uuid
- except Exception as e:
- # Fallback: dejar como string (puede no funcionar)
- filters['organizacion_id'] = org_value
- else: # Es CharField
- filters['organizacion'] = org_value
-
- # RFC - Manejar normalmente
- rfc_value = global_filters.get('rfc')
- if rfc_value and rfc_value != '' and 'rfc' in model_fields:
- filters['rfc'] = rfc_value
-
- # PATENTE
- if global_filters.get('patente'):
- filters['patente'] = global_filters['patente']
-
- # PEDIMENTO
- if global_filters.get('pedimento'):
- filters['pedimento'] = global_filters['pedimento']
-
- # FECHAS
- if 'fecha_pago_real' in model_fields:
- if global_filters.get('fecha_pago_desde'):
- filters['fecha_pago_real__gte'] = global_filters['fecha_pago_desde']
-
- if global_filters.get('fecha_pago_hasta'):
- filters['fecha_pago_real__lte'] = global_filters['fecha_pago_hasta']
-
- return filters
-
+ return datastage_export.apply_global_filters_to_model(global_filters, model)
+
def apply_related_filters(self, global_filters, model, related_keys, user):
- filters = {}
- model_fields = [f.name for f in model._meta.get_fields()]
-
- # 1. Organización — convertir a UUID igual que apply_global_filters_to_model
- if 'organizacion' in model_fields and global_filters.get('organizacion'):
- org_value = global_filters['organizacion']
- try:
- field = model._meta.get_field('organizacion')
- if hasattr(field, 'related_model'):
- filters['organizacion_id'] = uuid.UUID(org_value)
- else:
- filters['organizacion'] = org_value
- except Exception:
- filters['organizacion_id'] = org_value
-
- # 2. RFC (¡ESTO ES LO QUE FALTA!)
- if 'rfc' in model_fields and global_filters.get('rfc'):
- filters['rfc'] = global_filters['rfc']
-
- # 3. Fechas (SIEMPRE se aplican)
- if 'fecha_pago_real' in model_fields:
- if global_filters.get('fecha_pago_desde'):
- filters['fecha_pago_real__gte'] = global_filters['fecha_pago_desde']
-
- if global_filters.get('fecha_pago_hasta'):
- filters['fecha_pago_real__lte'] = global_filters['fecha_pago_hasta']
-
- # 🔥 SEGUNDO: Si hay related_keys, AÑADIRLAS a los filtros existentes
- if any(related_keys.values()):
-
- # Añadir patentes si existen
- if related_keys.get('patentes') and 'patente' in model_fields:
- filters['patente__in'] = related_keys['patentes']
-
- # Añadir pedimentos si existen
- if related_keys.get('pedimentos') and 'pedimento' in model_fields:
- filters['pedimento__in'] = related_keys['pedimentos']
-
- # Añadir datastage_ids si existen
- if related_keys.get('datastage_ids') and 'datastage_id' in model_fields:
- filters['datastage_id__in'] = related_keys['datastage_ids']
-
- else:
- # Solo patente y pedimento específicos (no listas)
- if 'patente' in model_fields and global_filters.get('patente'):
- filters['patente'] = global_filters['patente']
-
- if 'pedimento' in model_fields and global_filters.get('pedimento'):
- filters['pedimento'] = global_filters['pedimento']
-
- return filters
+ return datastage_export.apply_related_filters(global_filters, model, related_keys)
def estimate_excel_file_size(self, num_records, num_columns):
"""Estima tamaño aproximado del archivo Excel"""
diff --git a/config/settings.py b/config/settings.py
index 5e2685e..d8e920e 100644
--- a/config/settings.py
+++ b/config/settings.py
@@ -34,6 +34,13 @@ CELERY_BEAT_SCHEDULE = {
'task': 'api.customs.tasks.microservice_v2.process_all_organizations',
'schedule': crontab(hour=7, minute=1), # analizar si se requiere otra en un futuro
},
+ # Reintento recurrente de descargas VUCEM pendientes (T2026-05-027):
+ # cada ciclo incrementa el contador de intentos y, al agotar
+ # MAX_INTENTOS_AUTO, transiciona el registro a estado 'error'.
+ 'reintentar_descargas_pendientes': {
+ 'task': 'api.customs.tasks.microservice_v2.reintentar_descargas_pendientes',
+ 'schedule': crontab(minute='*/30'),
+ },
# '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
@@ -72,6 +79,11 @@ SITE_URL = os.getenv('SITE_URL')
SERVICE_API_URL = os.getenv('SERVICE_API_URL')
SERVICE_API_URL_V2 = os.getenv('SERVICE_API_URL_V2')
+# Tope de intentos automáticos de descarga VUCEM por registro (T2026-05-027).
+# Un intento = un ciclo de orquestación completo; los reintentos internos del
+# worker no incrementan el contador. Al llegar al tope solo queda el reproceso manual.
+MAX_INTENTOS_AUTO = int(os.getenv('MAX_INTENTOS_AUTO', '5'))
+
# Hub / SSO
HUB_URL = os.getenv('HUB_URL', 'https://workspace.aduanasoft.com')
HUB_PRODUCT_SLUG = os.getenv('HUB_PRODUCT_SLUG', 'efc')
@@ -340,7 +352,7 @@ CELERY_TIMEZONE = 'America/Denver'
ASGI_APPLICATION = 'config.asgi.application'
SIMPLE_JWT = {
- 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=59), # 1 hora — reduce frecuencia de refresh
+ 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15), # 1 hora — reduce frecuencia de refresh
'REFRESH_TOKEN_LIFETIME': timedelta(days=7), # 7 días — sesión larga
'ROTATE_REFRESH_TOKENS': False, # OFF — evita blacklist en múltiples tabs
'BLACKLIST_AFTER_ROTATION': False, # OFF — sin blacklist, múltiples tabs coexisten
diff --git a/core/utils.py b/core/utils.py
index 56d4671..2a1b8ba 100644
--- a/core/utils.py
+++ b/core/utils.py
@@ -150,13 +150,14 @@ class PedimentoScrapper: # Clase me extrae datos de Pedimento
def _remesas(self, root: ET.Element) -> bool:
"""
Método para verificar si el pedimento tiene remesas.
- Busca identificadores con clave 'RC' (REMESAS DE CONSOLIDADO).
-
+ Busca identificadores con clave 'PC' (PEDIMENTO CONSOLIDADO)
+ o 'RC' (REMESAS DE CONSOLIDADO).
+
Args:
root: Elemento raíz del XML.
-
+
Returns:
- True si encuentra identificadores con clave 'RC', False en caso contrario.
+ True si encuentra identificadores con clave 'PC' o 'RC', False en caso contrario.
"""
namespaces = {
'ns2': 'http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/consultarpedimentocompleto',
@@ -172,8 +173,8 @@ class PedimentoScrapper: # Clase me extrae datos de Pedimento
clave_elem = identificador.find('ns:claveIdentificador/ns:clave', namespaces)
clave = clave_elem.text if clave_elem is not None else None
- # Si encontramos una clave 'RC', el pedimento tiene remesas
- if clave == 'RC':
+ # PC (consolidado) o RC (remesas de consolidado) indican remesas
+ if clave in ('PC', 'RC'):
return True
except Exception as e:
diff --git a/scripts/t2026_05_027/01_deteccion_registros_afectados.sql b/scripts/t2026_05_027/01_deteccion_registros_afectados.sql
new file mode 100644
index 0000000..82eba48
--- /dev/null
+++ b/scripts/t2026_05_027/01_deteccion_registros_afectados.sql
@@ -0,0 +1,90 @@
+-- T2026-05-027 / Paso 1: detección de registros afectados (solo lectura)
+-- Ejecutar ANTES de la migración como línea base y DESPUÉS de la corrección
+-- (las queries deben regresar 0 filas al final).
+--
+-- Catálogo confirmado de document_type:
+-- 4 = acuse EDoc, 7 = acuse COVE, 19/23 = request COVE, 21/25 = request EDoc,
+-- 20 = error COVE, 22 = error EDoc, 24 = error acuse COVE, 26 = error acuse EDoc
+-- Confirmar con: SELECT id, nombre, descripcion FROM document_type ORDER BY id;
+
+-- (a) Acuses de EDocument marcados descargados SIN documento tipo 4 en BD
+SELECT e.id, e.numero_edocument, p.pedimento, p.id AS pedimento_id, e.organizacion_id
+FROM edocs e
+JOIN pedimento p ON p.id = e.pedimento_id
+WHERE e.acuse_descargado = TRUE
+ AND NOT EXISTS (
+ SELECT 1 FROM document d
+ WHERE d.pedimento_id = e.pedimento_id
+ AND d.document_type_id = 4
+ AND d.archivo ILIKE '%' || e.numero_edocument || '%'
+ );
+
+-- (b) EDocuments marcados descargados SIN documento general
+SELECT e.id, e.numero_edocument, p.pedimento
+FROM edocs e
+JOIN pedimento p ON p.id = e.pedimento_id
+WHERE e.edocument_descargado = TRUE
+ AND NOT EXISTS (
+ SELECT 1 FROM document d
+ WHERE d.pedimento_id = e.pedimento_id
+ AND d.archivo ILIKE '%' || e.numero_edocument || '%'
+ AND d.document_type_id NOT IN (4, 21, 22, 25, 26)
+ );
+
+-- (c) Acuses de COVE marcados descargados SIN documento tipo 7
+SELECT c.id, c.numero_cove, p.pedimento
+FROM coves c
+JOIN pedimento p ON p.id = c.pedimento_id
+WHERE c.acuse_cove_descargado = TRUE
+ AND NOT EXISTS (
+ SELECT 1 FROM document d
+ WHERE d.pedimento_id = c.pedimento_id
+ AND d.document_type_id = 7
+ AND d.archivo ILIKE '%' || c.numero_cove || '%'
+ );
+
+-- (d) COVEs marcados descargados SIN documento general
+SELECT c.id, c.numero_cove, p.pedimento
+FROM coves c
+JOIN pedimento p ON p.id = c.pedimento_id
+WHERE c.cove_descargado = TRUE
+ AND NOT EXISTS (
+ SELECT 1 FROM document d
+ WHERE d.pedimento_id = c.pedimento_id
+ AND d.archivo ILIKE '%' || c.numero_cove || '%'
+ AND d.document_type_id NOT IN (7, 19, 20, 23, 24)
+ );
+
+-- (e) Pendientes con evidencia de error (documentos tipo 20/22/24/26)
+SELECT 'cove_acuse_pendiente_con_error' AS categoria, c.id::text, c.numero_cove AS numero
+FROM coves c
+WHERE c.acuse_cove_descargado = FALSE
+ AND EXISTS (SELECT 1 FROM document d
+ WHERE d.pedimento_id = c.pedimento_id AND d.document_type_id = 24
+ AND d.archivo ILIKE '%' || c.numero_cove || '%')
+UNION ALL
+SELECT 'cove_pendiente_con_error', c.id::text, c.numero_cove
+FROM coves c
+WHERE c.cove_descargado = FALSE
+ AND EXISTS (SELECT 1 FROM document d
+ WHERE d.pedimento_id = c.pedimento_id AND d.document_type_id = 20
+ AND d.archivo ILIKE '%' || c.numero_cove || '%')
+UNION ALL
+SELECT 'edoc_pendiente_con_error', e.id::text, e.numero_edocument
+FROM edocs e
+WHERE e.edocument_descargado = FALSE
+ AND EXISTS (SELECT 1 FROM document d
+ WHERE d.pedimento_id = e.pedimento_id AND d.document_type_id = 22
+ AND d.archivo ILIKE '%' || e.numero_edocument || '%')
+UNION ALL
+SELECT 'edoc_acuse_pendiente_con_error', e.id::text, e.numero_edocument
+FROM edocs e
+WHERE e.acuse_descargado = FALSE
+ AND EXISTS (SELECT 1 FROM document d
+ WHERE d.pedimento_id = e.pedimento_id AND d.document_type_id = 26
+ AND d.archivo ILIKE '%' || e.numero_edocument || '%');
+
+-- (f) Verificación puntual del caso reportado por QA (pedimento del documento de pruebas)
+SELECT e.id, e.numero_edocument, e.edocument_descargado, e.acuse_descargado
+FROM edocs e
+WHERE e.pedimento_id = 'b4a6c3dd-5966-45a8-aa50-79b626ffd9c1';
diff --git a/scripts/t2026_05_027/02_backfill_estados.sql b/scripts/t2026_05_027/02_backfill_estados.sql
new file mode 100644
index 0000000..d469490
--- /dev/null
+++ b/scripts/t2026_05_027/02_backfill_estados.sql
@@ -0,0 +1,21 @@
+-- T2026-05-027 / Paso 2: backfill de estados (ejecutar DESPUÉS de aplicar la
+-- migración de customs que agrega *_estado, *_intentos, ultimo_intento_at, ultimo_error).
+-- Deriva el estado de 3 valores de los booleanos legados.
+
+BEGIN;
+
+-- Conteos de control: anotar y comparar contra las filas afectadas por cada UPDATE
+SELECT COUNT(*) AS total_edocs FROM edocs;
+SELECT COUNT(*) AS total_coves FROM coves;
+
+UPDATE edocs SET
+ edocument_estado = CASE WHEN edocument_descargado THEN 'descargado' ELSE 'pendiente' END,
+ acuse_estado = CASE WHEN acuse_descargado THEN 'descargado' ELSE 'pendiente' END;
+
+UPDATE coves SET
+ cove_estado = CASE WHEN cove_descargado THEN 'descargado' ELSE 'pendiente' END,
+ acuse_cove_estado = CASE WHEN acuse_cove_descargado THEN 'descargado' ELSE 'pendiente' END;
+
+-- Validar que cada UPDATE afectó exactamente el total de su tabla antes de confirmar:
+COMMIT;
+-- ROLLBACK; -- usar en su lugar si los conteos no cuadran
diff --git a/scripts/t2026_05_027/03_correccion_pendientes_con_error.sql b/scripts/t2026_05_027/03_correccion_pendientes_con_error.sql
new file mode 100644
index 0000000..902593d
--- /dev/null
+++ b/scripts/t2026_05_027/03_correccion_pendientes_con_error.sql
@@ -0,0 +1,85 @@
+-- T2026-05-027 / Paso 3: corrección masiva de pendientes con evidencia de error.
+-- Ejecutar DESPUÉS del backfill (02). Los registros 'pendiente' con documento de
+-- error asociado (tipos 20/22/24/26) transicionan a 'error' — quedan visibles y
+-- solo reprocesables de forma manual.
+--
+-- Los falsos "descargado" se corrigen con el comando (verifica también MinIO):
+-- python manage.py reconciliar_descargas -- dry-run
+-- python manage.py reconciliar_descargas --apply -- corrige
+
+BEGIN;
+
+-- Conteo de control (comparar contra filas afectadas por el UPDATE correspondiente)
+SELECT COUNT(*) AS coves_acuse_error FROM coves c
+WHERE c.acuse_cove_estado = 'pendiente'
+ AND EXISTS (SELECT 1 FROM document d
+ WHERE d.pedimento_id = c.pedimento_id AND d.document_type_id = 24
+ AND d.archivo ILIKE '%' || c.numero_cove || '%');
+
+UPDATE coves c SET
+ acuse_cove_estado = 'error',
+ acuse_cove_descargado = FALSE,
+ ultimo_error = 'Corrección T2026-05-027: acuse con documento de error (tipo 24) sin reintento',
+ updated_at = NOW()
+WHERE c.acuse_cove_estado = 'pendiente'
+ AND EXISTS (SELECT 1 FROM document d
+ WHERE d.pedimento_id = c.pedimento_id AND d.document_type_id = 24
+ AND d.archivo ILIKE '%' || c.numero_cove || '%');
+
+SELECT COUNT(*) AS coves_error FROM coves c
+WHERE c.cove_estado = 'pendiente'
+ AND EXISTS (SELECT 1 FROM document d
+ WHERE d.pedimento_id = c.pedimento_id AND d.document_type_id = 20
+ AND d.archivo ILIKE '%' || c.numero_cove || '%');
+
+UPDATE coves c SET
+ cove_estado = 'error',
+ cove_descargado = FALSE,
+ ultimo_error = 'Corrección T2026-05-027: COVE con documento de error (tipo 20) sin reintento',
+ updated_at = NOW()
+WHERE c.cove_estado = 'pendiente'
+ AND EXISTS (SELECT 1 FROM document d
+ WHERE d.pedimento_id = c.pedimento_id AND d.document_type_id = 20
+ AND d.archivo ILIKE '%' || c.numero_cove || '%');
+
+SELECT COUNT(*) AS edocs_error FROM edocs e
+WHERE e.edocument_estado = 'pendiente'
+ AND EXISTS (SELECT 1 FROM document d
+ WHERE d.pedimento_id = e.pedimento_id AND d.document_type_id = 22
+ AND d.archivo ILIKE '%' || e.numero_edocument || '%');
+
+UPDATE edocs e SET
+ edocument_estado = 'error',
+ edocument_descargado = FALSE,
+ ultimo_error = 'Corrección T2026-05-027: EDocument con documento de error (tipo 22) sin reintento',
+ updated_at = NOW()
+WHERE e.edocument_estado = 'pendiente'
+ AND EXISTS (SELECT 1 FROM document d
+ WHERE d.pedimento_id = e.pedimento_id AND d.document_type_id = 22
+ AND d.archivo ILIKE '%' || e.numero_edocument || '%');
+
+SELECT COUNT(*) AS edocs_acuse_error FROM edocs e
+WHERE e.acuse_estado = 'pendiente'
+ AND EXISTS (SELECT 1 FROM document d
+ WHERE d.pedimento_id = e.pedimento_id AND d.document_type_id = 26
+ AND d.archivo ILIKE '%' || e.numero_edocument || '%');
+
+UPDATE edocs e SET
+ acuse_estado = 'error',
+ acuse_descargado = FALSE,
+ ultimo_error = 'Corrección T2026-05-027: acuse con documento de error (tipo 26) sin reintento',
+ updated_at = NOW()
+WHERE e.acuse_estado = 'pendiente'
+ AND EXISTS (SELECT 1 FROM document d
+ WHERE d.pedimento_id = e.pedimento_id AND d.document_type_id = 26
+ AND d.archivo ILIKE '%' || e.numero_edocument || '%');
+
+COMMIT;
+-- ROLLBACK; -- usar en su lugar si los conteos no cuadran
+
+-- Censo final: distribución de estados (no debe existir valor fuera del catálogo)
+SELECT 'edocs' AS tabla, edocument_estado AS estado, COUNT(*) FROM edocs GROUP BY 2
+UNION ALL SELECT 'edocs_acuse', acuse_estado, COUNT(*) FROM edocs GROUP BY 2
+UNION ALL SELECT 'coves', cove_estado, COUNT(*) FROM coves GROUP BY 2
+UNION ALL SELECT 'coves_acuse', acuse_cove_estado, COUNT(*) FROM coves GROUP BY 2
+ORDER BY tabla, estado;