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

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

View File

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

View File

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

View File

@@ -1,31 +1,52 @@
"""
Diagnóstico y corrección de partidas con descargado=True cuyos documentos
de respuesta VUCEM contienen <tieneError>true</tieneError>.
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 <consultarPartidaRespuesta> sin <tieneError>true</tieneError>.
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 <UUID> --dry-run
python manage.py fix_partidas_error --organizacion <UUID> --dry-run
python manage.py fix_partidas_error --organizacion <UUID>
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(

View File

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

View File

@@ -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)'),
),
]

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = (
"<?xml version='1.0' encoding='UTF-8'?>"
'<S:Envelope xmlns:S="http://schemas.xmlsoap.org/soap/envelope/"><S:Body>'
'<ns9:consultarPartidaRespuesta xmlns:ns9="http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/consultarpartida">'
"<tieneError>false</tieneError><ns9:partida/></ns9:consultarPartidaRespuesta>"
"</S:Body></S:Envelope>"
)
XML_ERROR_VUCEM = (
"<?xml version='1.0' encoding='UTF-8'?>"
'<S:Envelope xmlns:S="http://schemas.xmlsoap.org/soap/envelope/"><S:Body>'
'<ns9:consultarPartidaRespuesta xmlns:ns9="http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/consultarpartida">'
"<tieneError>true</tieneError></ns9:consultarPartidaRespuesta>"
"</S:Body></S:Envelope>"
)
XML_ECO_REQUEST = (
"<?xml version='1.0' encoding='UTF-8'?>"
'<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"'
' xmlns:con="http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/consultarpartida"><soapenv:Body>'
"<con:consultarPartidaPeticion><con:peticion/></con:consultarPartidaPeticion>"
"</soapenv:Body></soapenv:Envelope>"
)
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})

View File

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

View File

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

View File

@@ -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')}),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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';

View File

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

View File

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