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