Compare commits
47 Commits
req--T2025
...
feature/T2
| Author | SHA1 | Date | |
|---|---|---|---|
| a13c1460c0 | |||
| dcabfb8762 | |||
| b805c791dc | |||
| 2e7d78fd8b | |||
| 244bbcb21c | |||
| d732602775 | |||
| 23ed52c78a | |||
| 7644446267 | |||
| cab3290f2e | |||
| d07c43e590 | |||
| e1716d65a7 | |||
| a9931d2838 | |||
| 709a5dedab | |||
| b1df613651 | |||
| 94846fec8a | |||
| e378f2d949 | |||
| a318b70324 | |||
| 9bbed42cf3 | |||
| 1966218081 | |||
| b57ce83dc5 | |||
|
|
c2ae752932 | ||
|
|
8cc0b9f573 | ||
|
|
3a636c14ae | ||
|
|
63f051c566 | ||
| c890e79394 | |||
|
|
39504e196c | ||
| 69d07f2713 | |||
|
|
27c8d24a56 | ||
| 627d78f4b8 | |||
|
|
4c7eb22b28 | ||
| 30b6d73567 | |||
|
|
460da47571 | ||
| 32aff7649e | |||
|
|
d115cdd072 | ||
| 28d2eaedda | |||
| f2bf904c84 | |||
| 271c562654 | |||
| 1c350cf2bf | |||
| e81a1aef4d | |||
| eca519a789 | |||
| 1dd05463c5 | |||
| cbbcb3b323 | |||
| 70999d413e | |||
| fa518972ba | |||
| 6299c6f0fe | |||
| 67f339bd18 | |||
| 6eaf6dc6d9 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -179,3 +179,4 @@ cython_debug/
|
|||||||
|
|
||||||
# End of https://www.toptal.com/developers/gitignore/api/django
|
# End of https://www.toptal.com/developers/gitignore/api/django
|
||||||
*.bak
|
*.bak
|
||||||
|
.vscode/
|
||||||
57
CHANGELOG.md
Normal file
57
CHANGELOG.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Changelog — EFC Backend
|
||||||
|
|
||||||
|
Historial de cambios por ticket (más reciente arriba). Cada entrada: fecha, ticket,
|
||||||
|
tipo, repos afectados, qué se hizo y por qué. Reglas del flujo en `../CLAUDE.md`.
|
||||||
|
|
||||||
|
## T2025-09-004 — Pertenencia documento→entidad (matching de partidas + FK polimórfica)
|
||||||
|
- **Fecha:** 2026-06-24
|
||||||
|
- **Tipo:** feature
|
||||||
|
- **Repos:** backend + microservice (el fix del stack legacy de descarga está en
|
||||||
|
`microservice/CHANGELOG.md`; frontend sin cambios)
|
||||||
|
- **Branch:** `feature/T2025-09-004` · **PR:** (pendiente de aceptación manual)
|
||||||
|
- **Qué se hizo:**
|
||||||
|
- Matching documento→partida con frontera `(_|.|$)` en `core/partida_docs.py` (cubre los
|
||||||
|
3 formatos de nombre sin confundir la partida 1 con 11/100).
|
||||||
|
- FK reales `document.partida` / `cove` / `edocument` (nullable, `CASCADE`); los acuses
|
||||||
|
cuelgan de su cove/edoc padre; los documentos nativos (PC, remesa, subidas generales)
|
||||||
|
quedan sin FK.
|
||||||
|
- Resolución central en `Document.save()` vía `core/document_links.py`: liga la FK por
|
||||||
|
`document_type` + nombre de archivo en toda ruta de creación (incluida la ingesta del
|
||||||
|
microservicio); set explícito de la FK en `create_vu_record`.
|
||||||
|
- Comando `backfill_document_links` para poblar la FK en filas existentes (idempotente).
|
||||||
|
- Comando `backfill_document_links_legacy` para documentos LEGADOS de cove/edoc con
|
||||||
|
nomenclatura vieja (otro `pedimento_app`/prefijo, p.ej. `vu_EDC_0201_800_..._{numero}`
|
||||||
|
en un pedimento `25-80-...`): liga por el `numero_cove`/`numero_edocument` único
|
||||||
|
presente en el nombre, sin exigir app ni prefijo. Solo cove/edoc (llaves únicas);
|
||||||
|
partida y nativos quedan fuera. Correr DESPUÉS del backfill estricto.
|
||||||
|
- Comando `dedup_documents` para limpiar documentos duplicados legados (misma entidad +
|
||||||
|
mismo tipo): conserva el más reciente con archivo válido en storage, borra el resto
|
||||||
|
(archivo MinIO si no lo referencia otra fila + fila + ajuste de cuota), `--dry-run`,
|
||||||
|
conteo previo, idempotente. Los duplicados eran **pre-fix**: la descarga ya reemplaza
|
||||||
|
en vez de re-crear desde jun-2026 (microservicio, `post_or_update_document`), verificado
|
||||||
|
con 0 duplicados creados después del fix. Solo aplica a docs ligados a entidad.
|
||||||
|
- Lectura, descarga y borrado SIEMPRE por la FK (id), nunca por nombre. El nombre solo
|
||||||
|
ESTABLECE la FK (en `Document.save()` para altas y en el backfill para filas viejas).
|
||||||
|
- **Rastreo del stack legacy (docs sin ligar/duplicados con datos de hoy):** el signal
|
||||||
|
`api/customs/signals/procesamiento.py` (post_save de Pedimento/Cove/EDocument, p.ej. al
|
||||||
|
cargar un datastage) dispara el stack VIEJO de descarga (`tasks/microservice.py` → api_v1
|
||||||
|
→ `utils/peticiones.py` del microservicio), que armaba el nombre con campos crudos (no
|
||||||
|
`pedimento_app`, prefijo `vu_EDC`) y posteaba sin reemplazar → docs sin FK + partidas
|
||||||
|
duplicadas. El auto-proceso (Celery beat `microservice_v2.process_all_organizations`) ya usa
|
||||||
|
el stack v2 correcto. Fix del módulo viejo en `microservice/CHANGELOG.md`. El signal/api_v1
|
||||||
|
se dejan activos (decisión: arreglar `peticiones.py`, no retirarlos); unificar a un solo
|
||||||
|
stack queda como follow-up.
|
||||||
|
- **Por qué:** retirar el matching frágil por nombre de archivo (`icontains`/prefijo, que
|
||||||
|
confundía entidades y se rompía con formatos nuevos) y tener la pertenencia
|
||||||
|
documento→entidad como dato real, consultable e íntegro.
|
||||||
|
- **Migraciones:** `0004_document_subentidad_fk` (campos, metadata-only),
|
||||||
|
`0005_document_subentidad_idx` (índices con `CREATE INDEX CONCURRENTLY IF NOT EXISTS`,
|
||||||
|
`atomic=False`, idempotente vía `SeparateDatabaseAndState`),
|
||||||
|
`0006_analyze_document` (`ANALYZE document`: refresca estadísticas del planner — sin esto,
|
||||||
|
el prefetch hacía seq scan sobre ~5M filas y los endpoints tardaban ~9s).
|
||||||
|
La tabla `document` tiene ~5M filas: cada índice tarda minutos y NO debe interrumpirse.
|
||||||
|
Recuperación si se corta: índices válidos → `migrate --fake record 0005`; alguno INVALID →
|
||||||
|
`DROP INDEX IF EXISTS "<nombre>";` y reintentar `migrate record`.
|
||||||
|
- **Despliegue (orden obligatorio):** aplicar migraciones (0004-0006) → **correr el backfill
|
||||||
|
completo** → recién entonces la lectura/descarga/borrado por FK es correcta. Como NO hay
|
||||||
|
fallback por nombre, un documento sin backfillear queda invisible hasta ligar su FK.
|
||||||
@@ -8,10 +8,10 @@ from rest_framework.response import Response
|
|||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
|
||||||
from core.permissions import (
|
from core.permissions import (
|
||||||
IsSameOrganization,
|
get_org_context,
|
||||||
IsSameOrganizationDeveloper,
|
require_permission,
|
||||||
IsSameOrganizationAndAdmin,
|
user_has_permission,
|
||||||
IsSuperUser
|
user_has_role,
|
||||||
)
|
)
|
||||||
|
|
||||||
from api.organization.models import UsoAlmacenamiento, Organizacion
|
from api.organization.models import UsoAlmacenamiento, Organizacion
|
||||||
@@ -34,7 +34,7 @@ class DocumentUtilInformation(LoggingMixin, APIView, FiltroPorOrganizacionMixin)
|
|||||||
View to get the total storage used by the organization and stats of documents added in last 1, 7, and 30 days.
|
View to get the total storage used by the organization and stats of documents added in last 1, 7, and 30 days.
|
||||||
Permite filtrar por fecha usando los parámetros ?fecha_inicio=YYYY-MM-DD&fecha_fin=YYYY-MM-DD
|
Permite filtrar por fecha usando los parámetros ?fecha_inicio=YYYY-MM-DD&fecha_fin=YYYY-MM-DD
|
||||||
"""
|
"""
|
||||||
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
|
permission_classes = [IsAuthenticated, require_permission('cards.view')]
|
||||||
model = Document
|
model = Document
|
||||||
|
|
||||||
my_tags = ['Cards']
|
my_tags = ['Cards']
|
||||||
@@ -100,7 +100,7 @@ class ViewPedimentoServicesUtilInformation(LoggingMixin, APIView, FiltroPorOrgan
|
|||||||
View para obtener información de uso de servicios relacionados con pedimentos.
|
View para obtener información de uso de servicios relacionados con pedimentos.
|
||||||
Devuelve la cantidad de procesos por estado (1: espera, 2: proceso, 3: finalizado, 4: error) para la organización.
|
Devuelve la cantidad de procesos por estado (1: espera, 2: proceso, 3: finalizado, 4: error) para la organización.
|
||||||
"""
|
"""
|
||||||
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
|
permission_classes = [IsAuthenticated, require_permission('cards.view')]
|
||||||
model = Document
|
model = Document
|
||||||
my_tags = ['Cards']
|
my_tags = ['Cards']
|
||||||
|
|
||||||
@@ -137,32 +137,28 @@ class ViewPedimentoServicesUtilInformation(LoggingMixin, APIView, FiltroPorOrgan
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get_queryset(self):
|
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
|
return None
|
||||||
|
|
||||||
# Si es super usuario, devuelve todos los procesos
|
org = get_org_context(user)
|
||||||
if self.request.user.is_superuser:
|
if not org:
|
||||||
return ProcesamientoPedimento.objects.all()
|
return ProcesamientoPedimento.objects.none()
|
||||||
|
|
||||||
# Si es Administrador de la organizacion devuelve todos los servicios de la organizacion
|
qs = ProcesamientoPedimento.objects.filter(pedimento__organizacion=org)
|
||||||
if self.request.user.is_authenticated and self.request.user.groups.filter(name='admin').exists() and self.request.user.groups.filter(name='Agente Aduanal').exists():
|
# Misma precedencia que los mixins de filtrado: superuser y roles
|
||||||
return ProcesamientoPedimento.objects.filter(pedimento__organizacion=self.request.user.organizacion)
|
# operativos ven todo lo de su org; is_importador no los degrada.
|
||||||
|
if (
|
||||||
# Si es Desarrollador de la organizacion devuelve todos los servicios de la organizacion
|
user.is_superuser or
|
||||||
if self.request.user.is_authenticated and self.request.user.groups.filter(name='developer').exists() and self.request.user.groups.filter(name='Agente Aduanal').exists():
|
user_has_role(user, 'admin') or
|
||||||
return self.request.user.organizacion.procesamiento_pedimentos.all()
|
user_has_role(user, 'developer') or
|
||||||
|
user_has_role(user, 'Agente Aduanal') or
|
||||||
if self.request.user.is_authenticated and self.request.user.groups.filter(name='user').exists() and self.request.user.groups.filter(name='Agente Aduanal').exists():
|
user_has_role(user, 'user')
|
||||||
return self.request.user.organizacion.procesamiento_pedimentos.all()
|
):
|
||||||
|
return qs
|
||||||
# Si es importador de la organizacion, devuelve los servicios relacionados con sus pedimentos
|
if user.is_importador:
|
||||||
if self.request.user.is_authenticated and self.request.user.groups.filter(name='importador').exists() and self.request.user.is_importador and self.request.user.groups.filter(name='user').exists():
|
return qs.filter(pedimento__contribuyente__in=user.rfc.all())
|
||||||
return self.request.user.organizacion.procesamiento_pedimentos.filter(pedimento__contribuyente=self.request.user.rfc)
|
return ProcesamientoPedimento.objects.none()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Si es parte de una organización, filtrar por esa organización
|
|
||||||
return ProcesamientoPedimento.objects.filter(pedimento__organizacion=self.request.user.organizacion)
|
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
queryset = self.get_queryset()
|
queryset = self.get_queryset()
|
||||||
@@ -193,12 +189,21 @@ class UserActivityAnalysis(LoggingMixin, APIView, FiltroPorOrganizacionMixin):
|
|||||||
Endpoint para análisis de actividades de usuario.
|
Endpoint para análisis de actividades de usuario.
|
||||||
Devuelve el conteo de acciones por tipo y los 5 usuarios más activos.
|
Devuelve el conteo de acciones por tipo y los 5 usuarios más activos.
|
||||||
"""
|
"""
|
||||||
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
|
permission_classes = [IsAuthenticated, require_permission('cards.view')]
|
||||||
|
|
||||||
model = UserActivity
|
model = UserActivity
|
||||||
|
campo_organizacion = 'user__organizacion'
|
||||||
|
|
||||||
my_tags = ['Cards']
|
my_tags = ['Cards']
|
||||||
|
|
||||||
|
def get_queryset_importador(self):
|
||||||
|
# Importadores solo ven sus propias actividades
|
||||||
|
user = self.request.user
|
||||||
|
org = get_org_context(user)
|
||||||
|
if not org:
|
||||||
|
return UserActivity.objects.none()
|
||||||
|
return UserActivity.objects.filter(user__organizacion=org, user=user)
|
||||||
|
|
||||||
@swagger_auto_schema(
|
@swagger_auto_schema(
|
||||||
operation_description="Get analysis of user activities. Permite filtrar por fecha de actividades.",
|
operation_description="Get analysis of user activities. Permite filtrar por fecha de actividades.",
|
||||||
manual_parameters=[
|
manual_parameters=[
|
||||||
@@ -253,7 +258,9 @@ class UserActivityAnalysis(LoggingMixin, APIView, FiltroPorOrganizacionMixin):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return self.get_queryset_filtrado()
|
if self.request.user.is_importador:
|
||||||
|
return self.get_queryset_importador()
|
||||||
|
return self.get_queryset_filtrado()
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
queryset = self.get_queryset()
|
queryset = self.get_queryset()
|
||||||
@@ -289,11 +296,20 @@ class RequestLogAnalysis(LoggingMixin, APIView, FiltroPorOrganizacionMixin):
|
|||||||
Endpoint para análisis de logs de peticiones.
|
Endpoint para análisis de logs de peticiones.
|
||||||
Devuelve el conteo por método, los paths más solicitados y el promedio de tiempo de respuesta.
|
Devuelve el conteo por método, los paths más solicitados y el promedio de tiempo de respuesta.
|
||||||
"""
|
"""
|
||||||
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
|
permission_classes = [IsAuthenticated, require_permission('cards.view')]
|
||||||
model = RequestLog
|
model = RequestLog
|
||||||
|
campo_organizacion = 'user__organizacion'
|
||||||
|
|
||||||
my_tags = ['Cards']
|
my_tags = ['Cards']
|
||||||
|
|
||||||
|
def get_queryset_importador(self):
|
||||||
|
# Importadores solo ven sus propios logs
|
||||||
|
user = self.request.user
|
||||||
|
org = get_org_context(user)
|
||||||
|
if not org:
|
||||||
|
return RequestLog.objects.none()
|
||||||
|
return RequestLog.objects.filter(user__organizacion=org, user=user)
|
||||||
|
|
||||||
@swagger_auto_schema(
|
@swagger_auto_schema(
|
||||||
operation_description="Get analysis of request logs. Permite filtrar por fecha de logs.",
|
operation_description="Get analysis of request logs. Permite filtrar por fecha de logs.",
|
||||||
manual_parameters=[
|
manual_parameters=[
|
||||||
@@ -345,6 +361,8 @@ class RequestLogAnalysis(LoggingMixin, APIView, FiltroPorOrganizacionMixin):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
|
if self.request.user.is_importador:
|
||||||
|
return self.get_queryset_importador()
|
||||||
return self.get_queryset_filtrado()
|
return self.get_queryset_filtrado()
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
@@ -376,7 +394,7 @@ class LastDocumentView(LoggingMixin, APIView, DocumentosFiltradosMixin):
|
|||||||
View que obtiene los ultimos 10 documentos agregados.
|
View que obtiene los ultimos 10 documentos agregados.
|
||||||
Permite filtrar por fecha usando los parámetros ?fecha_inicio=YYYY-MM-DD&fecha_fin=YYYY-MM-DD
|
Permite filtrar por fecha usando los parámetros ?fecha_inicio=YYYY-MM-DD&fecha_fin=YYYY-MM-DD
|
||||||
"""
|
"""
|
||||||
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
|
permission_classes = [IsAuthenticated, require_permission('cards.view')]
|
||||||
model = Document
|
model = Document
|
||||||
|
|
||||||
my_tags = ['Cards']
|
my_tags = ['Cards']
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class CustomUserCreationForm(UserCreationForm):
|
|||||||
class CustomUserChangeForm(UserChangeForm):
|
class CustomUserChangeForm(UserChangeForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CustomUser
|
model = CustomUser
|
||||||
fields = ('username', 'email', 'first_name', 'last_name', 'organizacion', 'profile_picture')
|
fields = ('username', 'email', 'first_name', 'last_name', 'organizacion', 'profile_picture', 'is_importador', 'rfc')
|
||||||
|
|
||||||
|
|
||||||
class CustomUserAdmin(UserAdmin):
|
class CustomUserAdmin(UserAdmin):
|
||||||
@@ -25,11 +25,12 @@ class CustomUserAdmin(UserAdmin):
|
|||||||
list_filter = ('is_staff', 'is_active', 'organizacion')
|
list_filter = ('is_staff', 'is_active', 'organizacion')
|
||||||
search_fields = ('username', 'email', 'first_name', 'last_name')
|
search_fields = ('username', 'email', 'first_name', 'last_name')
|
||||||
ordering = ('username',)
|
ordering = ('username',)
|
||||||
|
filter_horizontal = ('rfc', 'groups', 'user_permissions')
|
||||||
|
|
||||||
# Fieldsets para editar un usuario
|
# Fieldsets para editar un usuario
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {'fields': ('username', 'password')}),
|
(None, {'fields': ('username', 'password')}),
|
||||||
('Información personal', {'fields': ('first_name', 'last_name', 'email', 'organizacion', 'profile_picture', 'is_importador', 'rfc')}),
|
('Información personal', {'fields': ('first_name', 'last_name', 'email', 'organizacion', 'active_organization', 'profile_picture', 'is_importador', 'rfc')}),
|
||||||
('Permisos', {'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}),
|
('Permisos', {'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}),
|
||||||
('Fechas importantes', {'fields': ('last_login', 'date_joined')}),
|
('Fechas importantes', {'fields': ('last_login', 'date_joined')}),
|
||||||
)
|
)
|
||||||
|
|||||||
239
api/cuser/hub_auth.py
Normal file
239
api/cuser/hub_auth.py
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
"""
|
||||||
|
Autenticación vía Hub de Aduanasoft (Keycloak).
|
||||||
|
Tokens locales HS256 (~700 bytes) se emiten tras el exchange con el Hub
|
||||||
|
para no exceder el límite de 4096 bytes de cookies del browser.
|
||||||
|
|
||||||
|
ORDEN CRÍTICO en verify_hub_token:
|
||||||
|
cache → local HS256 → Hub /auth/me
|
||||||
|
Si el token local se manda al Hub primero, Hub responde 401 y rompe la
|
||||||
|
sesión SSO silenciosamente.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
import requests
|
||||||
|
from django.conf import settings
|
||||||
|
from rest_framework.authentication import BaseAuthentication
|
||||||
|
from rest_framework.exceptions import AuthenticationFailed
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Cache en memoria: {token: (payload, expires_at)}
|
||||||
|
_token_cache: dict = {}
|
||||||
|
_CACHE_TTL = 60 # segundos
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_get(token: str) -> Optional[dict]:
|
||||||
|
entry = _token_cache.get(token)
|
||||||
|
if entry and entry[1] > time.time():
|
||||||
|
return entry[0]
|
||||||
|
_token_cache.pop(token, None)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_set(token: str, payload: dict):
|
||||||
|
_token_cache[token] = (payload, time.time() + _CACHE_TTL)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tokens locales
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def create_local_tokens(user_data: dict) -> dict:
|
||||||
|
"""Emite tokens locales compactos HS256. Caben en cookies del browser."""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
base = {
|
||||||
|
"sub": str(user_data.get("id") or user_data.get("username", "")),
|
||||||
|
"preferred_username": user_data.get("username", ""),
|
||||||
|
"email": user_data.get("email", ""),
|
||||||
|
"name": user_data.get("name", ""),
|
||||||
|
"given_name": user_data.get("first_name", ""),
|
||||||
|
"family_name": user_data.get("last_name", ""),
|
||||||
|
"is_hub_admin": user_data.get("is_hub_admin", False),
|
||||||
|
"tenant_id": user_data.get("tenant_id"),
|
||||||
|
"tenant_slug": user_data.get("tenant_slug") or getattr(settings, "HUB_TENANT_SLUG", ""),
|
||||||
|
"source": "local",
|
||||||
|
"iat": int(now.timestamp()),
|
||||||
|
}
|
||||||
|
access_payload = {**base, "exp": int((now + timedelta(hours=8)).timestamp())}
|
||||||
|
refresh_payload = {**base, "exp": int((now + timedelta(days=30)).timestamp())}
|
||||||
|
secret = settings.SECRET_KEY
|
||||||
|
|
||||||
|
return {
|
||||||
|
"access_token": jwt.encode(access_payload, secret, algorithm="HS256"),
|
||||||
|
"refresh_token": jwt.encode(refresh_payload, secret, algorithm="HS256"),
|
||||||
|
"expires_in": 1800,
|
||||||
|
"source": "local",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_local_token(token: str) -> Optional[dict]:
|
||||||
|
"""Decodifica token local HS256. Retorna payload o None si no es local."""
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
|
||||||
|
if payload.get("source") == "local":
|
||||||
|
return payload
|
||||||
|
return None
|
||||||
|
except jwt.ExpiredSignatureError:
|
||||||
|
raise AuthenticationFailed("Token expirado — inicia sesión de nuevo")
|
||||||
|
except jwt.InvalidTokenError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Verificación contra Hub
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def verify_hub_token(token: str) -> dict:
|
||||||
|
"""ORDEN: cache → local HS256 → Hub /auth/me."""
|
||||||
|
cached = _cache_get(token)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
# 1. Token local primero (evita 401 del Hub para tokens locales)
|
||||||
|
local = _verify_local_token(token)
|
||||||
|
if local:
|
||||||
|
_cache_set(token, local)
|
||||||
|
return local
|
||||||
|
|
||||||
|
# 2. Validar contra Hub
|
||||||
|
hub_url = getattr(settings, "HUB_URL", "https://workspace.aduanasoft.com")
|
||||||
|
me_url = f"{hub_url.rstrip('/')}/api/v1/auth/me"
|
||||||
|
try:
|
||||||
|
r = requests.get(me_url, headers={"Authorization": f"Bearer {token}"}, timeout=5)
|
||||||
|
except requests.exceptions.RequestException as exc:
|
||||||
|
# Fallback: si hay token local válido lo usamos
|
||||||
|
local = _verify_local_token(token)
|
||||||
|
if local:
|
||||||
|
_cache_set(token, local)
|
||||||
|
return local
|
||||||
|
logger.error("Hub no disponible: %s", exc)
|
||||||
|
raise AuthenticationFailed("Servicio de autenticación no disponible")
|
||||||
|
|
||||||
|
if r.status_code == 200:
|
||||||
|
info = r.json()
|
||||||
|
_cache_set(token, info)
|
||||||
|
return info
|
||||||
|
|
||||||
|
if r.status_code in (401, 403):
|
||||||
|
raise AuthenticationFailed("Token inválido o sesión expirada")
|
||||||
|
|
||||||
|
logger.error("Hub respondió %s al verificar token", r.status_code)
|
||||||
|
raise AuthenticationFailed("No se pudo verificar el token")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_django_user(hub_data: dict):
|
||||||
|
"""Resuelve el CustomUser de Django a partir de los datos del Hub."""
|
||||||
|
from api.cuser.models import CustomUser
|
||||||
|
|
||||||
|
# Token local: sub puede ser Django UUID (login directo) o KC UUID (SSO exchange)
|
||||||
|
if hub_data.get("source") == "local":
|
||||||
|
from django.db.models import Q
|
||||||
|
sub = hub_data.get("sub", "")
|
||||||
|
if not sub:
|
||||||
|
return None
|
||||||
|
# Una sola query: busca por Django UUID o KC UUID simultáneamente
|
||||||
|
try:
|
||||||
|
return CustomUser.objects.filter(
|
||||||
|
Q(id=sub) | Q(keycloak_user_id=sub)
|
||||||
|
).first()
|
||||||
|
except Exception:
|
||||||
|
# sub malformado (no es UUID válido)
|
||||||
|
return CustomUser.objects.filter(keycloak_user_id=sub).first()
|
||||||
|
|
||||||
|
# Token Hub: buscar por keycloak_user_id → email
|
||||||
|
kc_id = hub_data.get("keycloak_user_id") or hub_data.get("sub")
|
||||||
|
email = hub_data.get("email")
|
||||||
|
|
||||||
|
if kc_id:
|
||||||
|
user = CustomUser.objects.filter(keycloak_user_id=kc_id).first()
|
||||||
|
if user:
|
||||||
|
return user
|
||||||
|
|
||||||
|
if email:
|
||||||
|
return CustomUser.objects.filter(email=email).first()
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DRF Authentication Backend
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class HubAuthBackend(BaseAuthentication):
|
||||||
|
"""
|
||||||
|
Drop-in para reemplazar JWTAuthentication.
|
||||||
|
Acepta tokens locales (HS256) y tokens del Hub indistintamente.
|
||||||
|
Se añade JUNTO a JWTAuthentication para compatibilidad durante la migración.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def authenticate(self, request):
|
||||||
|
token = self._extract_token(request)
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Detectar tokens SimpleJWT sin llamar al Hub.
|
||||||
|
# Decodificamos sin verificar firma solo para leer claims.
|
||||||
|
# Si el token no tiene source="local" ni claims de KC (realm_access, azp)
|
||||||
|
# es un token SimpleJWT legacy → dejar que JWTAuthentication lo maneje.
|
||||||
|
try:
|
||||||
|
unverified = jwt.decode(
|
||||||
|
token,
|
||||||
|
options={"verify_signature": False},
|
||||||
|
algorithms=["HS256", "RS256"],
|
||||||
|
)
|
||||||
|
is_hub_token = (
|
||||||
|
unverified.get("source") == "local" # token local HS256
|
||||||
|
or "realm_access" in unverified # token KC directo
|
||||||
|
or "azp" in unverified # token KC (authorized party)
|
||||||
|
)
|
||||||
|
if not is_hub_token:
|
||||||
|
return None # SimpleJWT — pasar al siguiente backend sin tocar el Hub
|
||||||
|
except Exception:
|
||||||
|
return None # JWT malformado — no es nuestro
|
||||||
|
|
||||||
|
try:
|
||||||
|
hub_data = verify_hub_token(token)
|
||||||
|
except AuthenticationFailed:
|
||||||
|
return None
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Error inesperado en HubAuthBackend: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
user = _get_django_user(hub_data)
|
||||||
|
if not user:
|
||||||
|
# Retornar None permite que endpoints AllowAny pasen sin bloquear.
|
||||||
|
# Los endpoints IsAuthenticated quedarán como "no autenticado" (sin 401 engañoso).
|
||||||
|
return None
|
||||||
|
|
||||||
|
return (user, token)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_token(request) -> Optional[str]:
|
||||||
|
auth_header = request.META.get("HTTP_AUTHORIZATION", "")
|
||||||
|
if auth_header.lower().startswith("bearer "):
|
||||||
|
return auth_header[7:].strip() or None
|
||||||
|
# Fallback: cookie (flujo SSO con cookies)
|
||||||
|
return request.COOKIES.get("access_token")
|
||||||
|
|
||||||
|
def authenticate_header(self, request):
|
||||||
|
return "Bearer"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helper cookies
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def set_session_cookies(response, tokens: dict):
|
||||||
|
"""Escribe las cookies de sesión HTTP-only."""
|
||||||
|
secure = getattr(settings, "COOKIE_SECURE", not settings.DEBUG)
|
||||||
|
kw = dict(httponly=True, secure=secure, samesite="Lax")
|
||||||
|
response.set_cookie("access_token", tokens["access_token"], max_age=1800, **kw)
|
||||||
|
response.set_cookie("refresh_token", tokens["refresh_token"], max_age=60*60*24*7, **kw)
|
||||||
|
response.set_cookie("token_type", "bearer", max_age=60*60*24*7,
|
||||||
|
httponly=False, secure=secure, samesite="Lax")
|
||||||
57
api/cuser/migrations/0005_customuser_rfc_fk_to_m2m.py
Normal file
57
api/cuser/migrations/0005_customuser_rfc_fk_to_m2m.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def copiar_rfc_a_m2m(apps, schema_editor):
|
||||||
|
"""Copia el RFC singular (FK) al lado M2M antes de eliminar el FK."""
|
||||||
|
CustomUser = apps.get_model('cuser', 'CustomUser')
|
||||||
|
db_alias = schema_editor.connection.alias
|
||||||
|
for user in CustomUser.objects.using(db_alias).filter(rfc_old__isnull=False):
|
||||||
|
user.rfc.add(user.rfc_old)
|
||||||
|
|
||||||
|
|
||||||
|
def revertir_m2m_a_fk(apps, schema_editor):
|
||||||
|
"""En reversa: toma el primer RFC del M2M y lo pone de vuelta en el FK temporal."""
|
||||||
|
CustomUser = apps.get_model('cuser', 'CustomUser')
|
||||||
|
db_alias = schema_editor.connection.alias
|
||||||
|
for user in CustomUser.objects.using(db_alias).prefetch_related('rfc'):
|
||||||
|
primer_rfc = user.rfc.first()
|
||||||
|
if primer_rfc:
|
||||||
|
user.rfc_old = primer_rfc
|
||||||
|
user.save(update_fields=['rfc_old'])
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('cuser', '0004_alter_customuser_rfc'),
|
||||||
|
('customs', '0015_partida_updated_at'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# 1. Renombrar el FK actual a rfc_old para preservar los datos
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='customuser',
|
||||||
|
old_name='rfc',
|
||||||
|
new_name='rfc_old',
|
||||||
|
),
|
||||||
|
# 2. Crear el nuevo campo M2M
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='customuser',
|
||||||
|
name='rfc',
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
help_text='RFCs de importadores asociados al usuario',
|
||||||
|
related_name='users',
|
||||||
|
to='customs.importador',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# 3. Copiar datos del FK al M2M
|
||||||
|
migrations.RunPython(copiar_rfc_a_m2m, revertir_m2m_a_fk),
|
||||||
|
# 4. Eliminar el FK temporal
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='customuser',
|
||||||
|
name='rfc_old',
|
||||||
|
),
|
||||||
|
]
|
||||||
25
api/cuser/migrations/0006_customuser_active_organization.py
Normal file
25
api/cuser/migrations/0006_customuser_active_organization.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('cuser', '0005_customuser_rfc_fk_to_m2m'),
|
||||||
|
('organization', '0003_organizacion_apply_auto_download'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='customuser',
|
||||||
|
name='active_organization',
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
help_text='Solo superusuarios: organización activa para contexto de trabajo',
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name='superusers_activos',
|
||||||
|
to='organization.organizacion',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
api/cuser/migrations/0007_customuser_keycloak_user_id.py
Normal file
18
api/cuser/migrations/0007_customuser_keycloak_user_id.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.3 on 2026-05-28 18:46
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('cuser', '0006_customuser_active_organization'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='customuser',
|
||||||
|
name='keycloak_user_id',
|
||||||
|
field=models.CharField(blank=True, help_text='UUID del usuario en Keycloak/Hub', max_length=36, null=True, unique=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -11,8 +11,22 @@ class CustomUser(AbstractUser):
|
|||||||
organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, null=True, blank=True, related_name='users')
|
organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, null=True, blank=True, related_name='users')
|
||||||
profile_picture = models.ImageField(upload_to='profile_pictures/', null=True, blank=True)
|
profile_picture = models.ImageField(upload_to='profile_pictures/', null=True, blank=True)
|
||||||
|
|
||||||
|
# Contexto de trabajo activo para superusuarios. Filtra datos igual que un usuario normal.
|
||||||
|
# Sin este campo activo, el superuser no puede consultar datos — debe hacer switch primero.
|
||||||
|
active_organization = models.ForeignKey(
|
||||||
|
'organization.Organizacion',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='superusers_activos',
|
||||||
|
help_text="Solo superusuarios: organización activa para contexto de trabajo",
|
||||||
|
)
|
||||||
|
|
||||||
is_importador = models.BooleanField(default=False, help_text="Indicates if the user is an importer")
|
is_importador = models.BooleanField(default=False, help_text="Indicates if the user is an importer")
|
||||||
rfc = models.ForeignKey('customs.Importador', on_delete=models.SET_NULL, null=True, blank=True, related_name='users', help_text="RFC associated with the user if they are an importer")
|
rfc = models.ManyToManyField('customs.Importador', blank=True, related_name='users', help_text="RFCs de importadores asociados al usuario")
|
||||||
|
|
||||||
|
# Identidad Keycloak — se llena con el script de migración masiva
|
||||||
|
keycloak_user_id = models.CharField(max_length=36, null=True, blank=True, unique=True, help_text="UUID del usuario en Keycloak/Hub")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.username
|
return self.username
|
||||||
|
|||||||
@@ -2,28 +2,62 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from .models import CustomUser
|
from .models import CustomUser
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
|
from api.customs.models import Importador
|
||||||
|
|
||||||
class CustomUserSerializer(serializers.ModelSerializer):
|
class CustomUserSerializer(serializers.ModelSerializer):
|
||||||
"""
|
"""
|
||||||
Serializer for the CustomUser model.
|
Serializer for the CustomUser model.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
password = serializers.CharField(write_only=True)
|
password = serializers.CharField(write_only=True, required=False)
|
||||||
groups = serializers.PrimaryKeyRelatedField(queryset=Group.objects.all(), many=True, required=False)
|
groups = serializers.PrimaryKeyRelatedField(queryset=Group.objects.all(), many=True, required=False)
|
||||||
rfc = serializers.CharField(max_length=20, required=False, allow_blank=True)
|
rfc = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=Importador.objects.all(),
|
||||||
|
many=True,
|
||||||
|
required=False,
|
||||||
|
pk_field=serializers.CharField(),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CustomUser
|
model = CustomUser
|
||||||
fields = ['id', 'username', 'email', 'first_name', 'last_name', 'password', 'profile_picture', 'organizacion', 'is_importador', 'rfc', 'is_active', 'is_superuser', 'groups']
|
fields = ['id', 'username', 'email', 'first_name', 'last_name', 'password', 'profile_picture', 'organizacion', 'is_importador', 'rfc', 'is_active', 'is_superuser', 'groups']
|
||||||
read_only_fields = ['id', 'organizacion', 'is_superuser']
|
read_only_fields = ['id', 'organizacion', 'is_superuser']
|
||||||
|
|
||||||
|
def validate_password(self, value):
|
||||||
|
if not value or not value.strip():
|
||||||
|
raise serializers.ValidationError("La contraseña no puede estar vacía o contener solo espacios.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
# En create, la contraseña es obligatoria
|
||||||
|
if self.instance is None and not attrs.get('password'):
|
||||||
|
raise serializers.ValidationError({"password": "Este campo es requerido."})
|
||||||
|
return attrs
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
groups = validated_data.pop('groups', [])
|
groups = validated_data.pop('groups', [])
|
||||||
|
rfcs = validated_data.pop('rfc', [])
|
||||||
password = validated_data.pop('password')
|
password = validated_data.pop('password')
|
||||||
user = CustomUser(**validated_data)
|
user = CustomUser(**validated_data)
|
||||||
user.set_password(password)
|
user.set_password(password)
|
||||||
user.save()
|
user.save()
|
||||||
if groups:
|
if groups:
|
||||||
user.groups.set(groups)
|
user.groups.set(groups)
|
||||||
|
if rfcs:
|
||||||
|
user.rfc.set(rfcs)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
groups = validated_data.pop('groups', None)
|
||||||
|
rfcs = validated_data.pop('rfc', None)
|
||||||
|
password = validated_data.pop('password', None)
|
||||||
|
for attr, value in validated_data.items():
|
||||||
|
setattr(instance, attr, value)
|
||||||
|
if password:
|
||||||
|
instance.set_password(password)
|
||||||
|
instance.save()
|
||||||
|
if groups is not None:
|
||||||
|
instance.groups.set(groups)
|
||||||
|
if rfcs is not None:
|
||||||
|
instance.rfc.set(rfcs)
|
||||||
|
return instance
|
||||||
|
|||||||
11
api/cuser/sso_urls.py
Normal file
11
api/cuser/sso_urls.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from .sso_views import login_view, sso_exchange_view, me_view, logout_view, refresh_view, session_refresh_view
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("login/", login_view, name="hub-login"),
|
||||||
|
path("sso/exchange/", sso_exchange_view, name="hub-sso-exchange"),
|
||||||
|
path("me/", me_view, name="hub-me"),
|
||||||
|
path("logout/", logout_view, name="hub-logout"),
|
||||||
|
path("login/refresh/", refresh_view, name="hub-refresh"), # legacy
|
||||||
|
path("session/refresh/", session_refresh_view, name="hub-session-refresh"), # cookie-based
|
||||||
|
]
|
||||||
564
api/cuser/sso_views.py
Normal file
564
api/cuser/sso_views.py
Normal file
@@ -0,0 +1,564 @@
|
|||||||
|
"""
|
||||||
|
Vistas SSO para integración con Hub de Aduanasoft.
|
||||||
|
Cuatro endpoints:
|
||||||
|
POST /api/v1/auth/login/ — login directo email/password (proxy Hub)
|
||||||
|
POST /api/v1/auth/sso/exchange/ — canjea relay token por sesión local
|
||||||
|
GET /api/v1/auth/me/ — usuario autenticado actual
|
||||||
|
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
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from .hub_auth import (
|
||||||
|
create_local_tokens,
|
||||||
|
set_session_cookies,
|
||||||
|
verify_hub_token,
|
||||||
|
_get_django_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
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 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.select_related('organizacion').filter(
|
||||||
|
Q(username=username) | Q(email=username),
|
||||||
|
is_active=True,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
return False
|
||||||
|
|
||||||
|
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,
|
||||||
|
"tenant_name": org.nombre,
|
||||||
|
"product_slug": "efc",
|
||||||
|
"role": role,
|
||||||
|
"new_tenant": True,
|
||||||
|
},
|
||||||
|
headers={"X-Provision-Secret": provision_secret},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
|
||||||
|
if r.status_code == 200:
|
||||||
|
data = r.json()
|
||||||
|
# Hub devuelve access_token (JWT KC) — extraer sub = KC user UUID
|
||||||
|
kc_id = data.get("user_id") or data.get("keycloak_user_id")
|
||||||
|
if not kc_id:
|
||||||
|
try:
|
||||||
|
import jwt as _jwt
|
||||||
|
payload = _jwt.decode(
|
||||||
|
data["access_token"],
|
||||||
|
options={"verify_signature": False},
|
||||||
|
algorithms=["RS256", "HS256"],
|
||||||
|
)
|
||||||
|
kc_id = payload.get("sub")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if kc_id:
|
||||||
|
CustomUser.objects.filter(pk=user.pk).update(keycloak_user_id=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
|
||||||
|
|
||||||
|
logger.error("[provision] Hub %s al provisionar %s: %s",
|
||||||
|
r.status_code, username, r.text[:200])
|
||||||
|
return False
|
||||||
|
|
||||||
|
except http.exceptions.RequestException as exc:
|
||||||
|
logger.error("[provision] Error de red provisionando %s: %s", username, exc)
|
||||||
|
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 "):
|
||||||
|
t = auth[7:].strip()
|
||||||
|
if t:
|
||||||
|
return t
|
||||||
|
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/
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@api_view(["POST"])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
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).
|
||||||
|
"""
|
||||||
|
from django.contrib.auth import authenticate as django_auth
|
||||||
|
from django.db.models import Q
|
||||||
|
from api.cuser.models import CustomUser
|
||||||
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
|
|
||||||
|
username = request.data.get("username", "").strip()
|
||||||
|
password = request.data.get("password", "")
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
return Response({"detail": "username y password son requeridos"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
user = django_auth(request, username=username, password=password)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
user_by_email = CustomUser.objects.filter(
|
||||||
|
Q(email=username), is_active=True
|
||||||
|
).first()
|
||||||
|
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)
|
||||||
|
|
||||||
|
first_login = not bool(user.keycloak_user_id)
|
||||||
|
if first_login:
|
||||||
|
import threading
|
||||||
|
|
||||||
|
def _provision_async():
|
||||||
|
try:
|
||||||
|
_provision_user_in_hub(user.username, password)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("[login] Provisión async fallida para %s: %s", user.username, exc)
|
||||||
|
|
||||||
|
threading.Thread(target=_provision_async, daemon=True).start()
|
||||||
|
logger.info("[login] Provisión iniciada en background para %s", user.username)
|
||||||
|
|
||||||
|
refresh = RefreshToken.for_user(user)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
"access": str(refresh.access_token),
|
||||||
|
"refresh": str(refresh),
|
||||||
|
"access_token": str(refresh.access_token),
|
||||||
|
"refresh_token": str(refresh),
|
||||||
|
"first_login": first_login,
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"username": user.username,
|
||||||
|
"email": user.email,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /api/v1/auth/sso/exchange/
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@api_view(["POST"])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
def sso_exchange_view(request):
|
||||||
|
"""
|
||||||
|
Canjea relay token del Hub por sesión local.
|
||||||
|
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:
|
||||||
|
return Response({"detail": "relay_token requerido"}, status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = http.post(
|
||||||
|
f"{HUB_URL()}/api/v1/auth/sso-exchange",
|
||||||
|
json={"relay_token": relay_token},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
except http.exceptions.RequestException as exc:
|
||||||
|
logger.error("Hub no disponible en SSO exchange: %s", exc)
|
||||||
|
return Response({"detail": "Servicio de autenticación no disponible"}, status=503)
|
||||||
|
|
||||||
|
if r.status_code == 404:
|
||||||
|
return Response({"detail": "Relay token inválido o expirado"}, status=401)
|
||||||
|
if r.status_code != 200:
|
||||||
|
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()
|
||||||
|
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", ""),
|
||||||
|
"email": data.get("email", ""),
|
||||||
|
"name": data.get("name", ""),
|
||||||
|
"first_name": "",
|
||||||
|
"last_name": "",
|
||||||
|
"is_hub_admin": data.get("is_hub_admin", False),
|
||||||
|
"tenant_id": data.get("tenant_id"),
|
||||||
|
"tenant_slug": tenant_slug,
|
||||||
|
})
|
||||||
|
|
||||||
|
response = Response({
|
||||||
|
"user_id": data.get("user_id"),
|
||||||
|
"email": data.get("email"),
|
||||||
|
"name": data.get("name"),
|
||||||
|
"username": data.get("preferred_username"),
|
||||||
|
"tenant_id": data.get("tenant_id"),
|
||||||
|
"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"), tenant_slug)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/v1/auth/me/
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@api_view(["GET"])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
def me_view(request):
|
||||||
|
"""Retorna el usuario autenticado actual desde token o cookie."""
|
||||||
|
token = _extract_token(request)
|
||||||
|
if not token:
|
||||||
|
return Response({"detail": "No autenticado"}, status=401)
|
||||||
|
|
||||||
|
try:
|
||||||
|
hub_data = verify_hub_token(token)
|
||||||
|
except Exception as exc:
|
||||||
|
return Response({"detail": str(exc)}, status=401)
|
||||||
|
|
||||||
|
# Intentar enriquecer con datos Django si el usuario existe
|
||||||
|
user = _get_django_user(hub_data)
|
||||||
|
|
||||||
|
if user:
|
||||||
|
return Response({
|
||||||
|
"id": str(user.id),
|
||||||
|
"username": user.username,
|
||||||
|
"email": user.email,
|
||||||
|
"name": f"{user.first_name} {user.last_name}".strip() or hub_data.get("name", ""),
|
||||||
|
"first_name": user.first_name,
|
||||||
|
"last_name": user.last_name,
|
||||||
|
"is_superuser": user.is_superuser,
|
||||||
|
"is_hub_admin": hub_data.get("is_hub_admin", False),
|
||||||
|
"tenant_id": hub_data.get("tenant_id"),
|
||||||
|
"tenant_slug": hub_data.get("tenant_slug"),
|
||||||
|
"avatar_url": hub_data.get("avatar_url"),
|
||||||
|
"organizacion_id": str(user.organizacion_id) if user.organizacion_id else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
"id": hub_data.get("sub"),
|
||||||
|
"username": hub_data.get("preferred_username") or hub_data.get("email", ""),
|
||||||
|
"email": hub_data.get("email"),
|
||||||
|
"name": hub_data.get("name", ""),
|
||||||
|
"first_name": hub_data.get("given_name", ""),
|
||||||
|
"last_name": hub_data.get("family_name", ""),
|
||||||
|
"is_superuser": hub_data.get("is_hub_admin", False),
|
||||||
|
"is_hub_admin": hub_data.get("is_hub_admin", False),
|
||||||
|
"tenant_id": hub_data.get("tenant_id"),
|
||||||
|
"tenant_slug": hub_data.get("tenant_slug"),
|
||||||
|
"avatar_url": hub_data.get("avatar_url"),
|
||||||
|
"organizacion_id": None,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /api/v1/auth/logout/
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@api_view(["POST"])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
def logout_view(request):
|
||||||
|
"""Limpia cookies de sesión. El frontend redirige al Hub para cerrar KC."""
|
||||||
|
response = Response({"detail": "Sesión cerrada"})
|
||||||
|
for cookie in ("access_token", "refresh_token", "token_type"):
|
||||||
|
response.delete_cookie(cookie, samesite="Lax")
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /api/v1/auth/login/refresh/
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@api_view(["POST"])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
def refresh_view(request):
|
||||||
|
"""Renueva el access token usando el refresh token local."""
|
||||||
|
refresh_token = (
|
||||||
|
request.data.get("refresh_token")
|
||||||
|
or request.COOKIES.get("refresh_token")
|
||||||
|
)
|
||||||
|
if not refresh_token:
|
||||||
|
return Response({"detail": "refresh_token requerido"}, status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import jwt as pyjwt
|
||||||
|
payload = pyjwt.decode(refresh_token, settings.SECRET_KEY, algorithms=["HS256"])
|
||||||
|
if payload.get("source") != "local":
|
||||||
|
return Response({"detail": "Token de refresco inválido"}, status=401)
|
||||||
|
except pyjwt.ExpiredSignatureError:
|
||||||
|
return Response({"detail": "Refresh token expirado"}, status=401)
|
||||||
|
except pyjwt.InvalidTokenError:
|
||||||
|
return Response({"detail": "Refresh token inválido"}, status=401)
|
||||||
|
|
||||||
|
new_tokens = create_local_tokens({
|
||||||
|
"id": payload.get("sub"),
|
||||||
|
"username": payload.get("preferred_username", ""),
|
||||||
|
"email": payload.get("email", ""),
|
||||||
|
"name": payload.get("name", ""),
|
||||||
|
"first_name": payload.get("given_name", ""),
|
||||||
|
"last_name": payload.get("family_name", ""),
|
||||||
|
"is_hub_admin": payload.get("is_hub_admin", False),
|
||||||
|
"tenant_id": payload.get("tenant_id"),
|
||||||
|
"tenant_slug": payload.get("tenant_slug"),
|
||||||
|
})
|
||||||
|
|
||||||
|
response = Response({"access_token": new_tokens["access_token"]})
|
||||||
|
set_session_cookies(response, new_tokens)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /api/v1/auth/session/refresh/
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@api_view(["POST"])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
refresh_token = request.COOKIES.get("refresh_token")
|
||||||
|
if not refresh_token:
|
||||||
|
return Response({"detail": "No hay sesión activa"}, status=401)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import jwt as pyjwt
|
||||||
|
payload = pyjwt.decode(refresh_token, settings.SECRET_KEY, algorithms=["HS256"])
|
||||||
|
if payload.get("source") != "local":
|
||||||
|
return Response({"detail": "Token de refresco inválido"}, status=401)
|
||||||
|
except pyjwt.ExpiredSignatureError:
|
||||||
|
return Response({"detail": "Sesión expirada — inicia sesión de nuevo"}, status=401)
|
||||||
|
except pyjwt.InvalidTokenError:
|
||||||
|
return Response({"detail": "Token de refresco inválido"}, status=401)
|
||||||
|
|
||||||
|
new_tokens = create_local_tokens({
|
||||||
|
"id": payload.get("sub"),
|
||||||
|
"username": payload.get("preferred_username", ""),
|
||||||
|
"email": payload.get("email", ""),
|
||||||
|
"name": payload.get("name", ""),
|
||||||
|
"first_name": payload.get("given_name", ""),
|
||||||
|
"last_name": payload.get("family_name", ""),
|
||||||
|
"is_hub_admin": payload.get("is_hub_admin", False),
|
||||||
|
"tenant_id": payload.get("tenant_id"),
|
||||||
|
"tenant_slug": payload.get("tenant_slug"),
|
||||||
|
})
|
||||||
|
|
||||||
|
access = new_tokens["access_token"]
|
||||||
|
response = Response({
|
||||||
|
"access_token": access,
|
||||||
|
"access": access,
|
||||||
|
})
|
||||||
|
set_session_cookies(response, new_tokens)
|
||||||
|
return response
|
||||||
@@ -20,7 +20,11 @@ from core.permissions import (
|
|||||||
IsSameOrganization,
|
IsSameOrganization,
|
||||||
IsSameOrganizationDeveloper,
|
IsSameOrganizationDeveloper,
|
||||||
IsSameOrganizationAndAdmin,
|
IsSameOrganizationAndAdmin,
|
||||||
IsSuperUser
|
IsSuperUser,
|
||||||
|
get_org_context,
|
||||||
|
is_internal_service_request,
|
||||||
|
user_has_permission,
|
||||||
|
require_permission,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .serializers import CustomUserSerializer
|
from .serializers import CustomUserSerializer
|
||||||
@@ -74,78 +78,62 @@ class CustomUserViewSet(viewsets.ModelViewSet, OrganizacionFiltradaMixin):
|
|||||||
"""
|
"""
|
||||||
ViewSet for CustomUser model.
|
ViewSet for CustomUser model.
|
||||||
"""
|
"""
|
||||||
permission_classes = [IsAuthenticated & (IsSuperUser | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSameOrganization )]
|
|
||||||
pagination_class = CustomPagination
|
pagination_class = CustomPagination
|
||||||
model = CustomUser
|
model = CustomUser
|
||||||
serializer_class = CustomUserSerializer
|
serializer_class = CustomUserSerializer
|
||||||
filterset_fields = ['username', 'email', 'first_name', 'last_name', 'organizacion', 'is_importador']
|
filterset_fields = ['username', 'email', 'first_name', 'last_name', 'organizacion', 'is_importador']
|
||||||
my_tags = ['User Profile']
|
my_tags = ['User Profile']
|
||||||
|
|
||||||
|
|
||||||
def get_permissions(self):
|
def get_permissions(self):
|
||||||
# Permitir eliminar usuarios solo a admin, Agente Aduanal y user de la misma organización
|
if self.action in ('me', 'change_password'):
|
||||||
if self.action == 'destroy':
|
return [IsAuthenticated()]
|
||||||
user = self.request.user
|
perms = {
|
||||||
if not (
|
'list': 'usuarios.view',
|
||||||
user.is_superuser or
|
'retrieve': 'usuarios.view',
|
||||||
user.groups.filter(name='admin').exists() or
|
'create': 'usuarios.create',
|
||||||
user.groups.filter(name='Agente Aduanal').exists() or
|
'update': 'usuarios.edit',
|
||||||
user.groups.filter(name='user').exists()
|
'partial_update': 'usuarios.edit',
|
||||||
):
|
'destroy': 'usuarios.delete',
|
||||||
from rest_framework.exceptions import PermissionDenied
|
}
|
||||||
raise PermissionDenied("Solo admin, Agente Aduanal o user pueden eliminar usuarios.")
|
codename = perms.get(self.action, 'usuarios.view')
|
||||||
elif self.action in ['create', 'update', 'partial_update']:
|
return [IsAuthenticated(), require_permission(codename)()]
|
||||||
if not (self.request.user.is_superuser or self.request.user.groups.filter(name='admin').exists() or self.request.user.groups.filter(name='Importador').exists()) :
|
|
||||||
from rest_framework.exceptions import PermissionDenied
|
|
||||||
raise PermissionDenied("Solo admin o superusuario pueden modificar usuarios.")
|
|
||||||
return super().get_permissions()
|
|
||||||
|
|
||||||
def perform_destroy(self, instance):
|
def perform_destroy(self, instance):
|
||||||
# Solo permitir eliminar usuarios de la misma organización
|
user = self.request.user
|
||||||
if self.request.user.is_superuser or instance.organizacion == self.request.user.organizacion:
|
org = get_org_context(user)
|
||||||
|
if user.is_superuser or instance.organizacion == org:
|
||||||
instance.delete()
|
instance.delete()
|
||||||
else:
|
else:
|
||||||
from rest_framework.exceptions import PermissionDenied
|
|
||||||
raise PermissionDenied("Solo puedes eliminar usuarios de tu organización.")
|
raise PermissionDenied("Solo puedes eliminar usuarios de tu organización.")
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
# Si es importador, solo puede ver su propio usuario
|
user = self.request.user
|
||||||
if self.request.user.groups.filter(name='importador').exists() or self.request.user.groups.filter(name='Importador').exists():
|
if is_internal_service_request(self.request):
|
||||||
return CustomUser.objects.filter(pk=self.request.user.pk)
|
return CustomUser.objects.all()
|
||||||
|
if not user_has_permission(user, 'usuarios.view'):
|
||||||
# Otros roles: filtrar por organización
|
return CustomUser.objects.none()
|
||||||
return self.get_queryset_filtrado_por_organizacion()
|
org = get_org_context(user)
|
||||||
|
if not org:
|
||||||
|
return CustomUser.objects.none()
|
||||||
|
return CustomUser.objects.filter(organizacion=org)
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
# Always assign the creator's organization
|
creator = self.request.user
|
||||||
if self.request.user.groups.filter(name='admin').exists() and self.request.user.groups.filter(name='Agente Aduanal').exists():
|
|
||||||
if not self.request.user.organizacion:
|
|
||||||
raise PermissionDenied("Los administradores deben tener una organización asignada para crear usuarios.")
|
|
||||||
user = serializer.save(organizacion=self.request.user.organizacion, is_active=False)
|
|
||||||
send_activation_email(user, self.request) # Usa template HTML
|
|
||||||
return
|
|
||||||
|
|
||||||
if self.request.user.is_superuser:
|
if creator.is_superuser:
|
||||||
# If superuser, allow creating users without organization
|
|
||||||
user = serializer.save(is_active=False)
|
user = serializer.save(is_active=False)
|
||||||
send_activation_email(user, self.request) # Usa template HTML
|
send_activation_email(user, self.request)
|
||||||
return
|
return
|
||||||
|
|
||||||
if self.request.user.groups.filter(name='developer').exists():
|
if creator.is_importador:
|
||||||
# Developers can create users but must assign an organization
|
|
||||||
if not self.request.user.organizacion:
|
|
||||||
raise PermissionDenied("Los desarrolladores deben tener una organización asignada para crear usuarios.")
|
|
||||||
user = serializer.save(organizacion=self.request.user.organizacion, is_active=False)
|
|
||||||
send_activation_email(user, self.request) # Usa template HTML
|
|
||||||
return
|
|
||||||
|
|
||||||
if self.request.user.groups.filter(name='importador').exists():
|
|
||||||
# No puedes crear un usuario si eres importador
|
|
||||||
raise PermissionDenied("Los importadores no pueden crear usuarios.")
|
raise PermissionDenied("Los importadores no pueden crear usuarios.")
|
||||||
|
|
||||||
user = serializer.save(organizacion=self.request.user.organizacion, is_active=False)
|
org = get_org_context(creator)
|
||||||
send_activation_email(user, self.request) # Usa template HTML
|
if not org:
|
||||||
return
|
raise PermissionDenied("Debes tener una organización asignada para crear usuarios.")
|
||||||
|
|
||||||
|
user = serializer.save(organizacion=org, is_active=False)
|
||||||
|
send_activation_email(user, self.request)
|
||||||
|
|
||||||
@action(detail=False, methods=['get'], permission_classes=[IsAuthenticated])
|
@action(detail=False, methods=['get'], permission_classes=[IsAuthenticated])
|
||||||
def me(self, request):
|
def me(self, request):
|
||||||
@@ -167,8 +155,11 @@ class CustomUserViewSet(viewsets.ModelViewSet, OrganizacionFiltradaMixin):
|
|||||||
"""
|
"""
|
||||||
user = self.get_object()
|
user = self.get_object()
|
||||||
current_user = request.user
|
current_user = request.user
|
||||||
# Solo el propio usuario, admin o superuser pueden cambiar la contraseña
|
puede_cambiar_ajena = (
|
||||||
if not (current_user.is_superuser or current_user.groups.filter(name='admin').exists() or user == current_user):
|
current_user.is_superuser or
|
||||||
|
user_has_permission(current_user, 'usuarios.change_password')
|
||||||
|
)
|
||||||
|
if not (puede_cambiar_ajena or user == current_user):
|
||||||
raise PermissionDenied("No tienes permiso para cambiar la contraseña de este usuario.")
|
raise PermissionDenied("No tienes permiso para cambiar la contraseña de este usuario.")
|
||||||
|
|
||||||
old_password = request.data.get('old_password')
|
old_password = request.data.get('old_password')
|
||||||
@@ -176,8 +167,7 @@ class CustomUserViewSet(viewsets.ModelViewSet, OrganizacionFiltradaMixin):
|
|||||||
if not new_password:
|
if not new_password:
|
||||||
return Response({'detail': 'La nueva contraseña es requerida.'}, status=400)
|
return Response({'detail': 'La nueva contraseña es requerida.'}, status=400)
|
||||||
|
|
||||||
# Si no es admin/superuser, debe validar old_password
|
if not puede_cambiar_ajena:
|
||||||
if not (current_user.is_superuser or current_user.groups.filter(name='admin').exists()):
|
|
||||||
if not old_password or not user.check_password(old_password):
|
if not old_password or not user.check_password(old_password):
|
||||||
return Response({'detail': 'La contraseña actual es incorrecta.'}, status=400)
|
return Response({'detail': 'La contraseña actual es incorrecta.'}, status=400)
|
||||||
|
|
||||||
@@ -226,11 +216,11 @@ class ProfilePictureView(LoggingMixin, APIView):
|
|||||||
my_tags = ['User Profile']
|
my_tags = ['User Profile']
|
||||||
|
|
||||||
def get(self, request, user_id):
|
def get(self, request, user_id):
|
||||||
# Obtiene el usuario (automáticamente 404 si no existe)
|
|
||||||
user = get_object_or_404(CustomUser, pk=user_id)
|
user = get_object_or_404(CustomUser, pk=user_id)
|
||||||
|
|
||||||
# El permiso IsOwnerOrAdmin ya verificó que request.user == user o es admin
|
org = get_org_context(request.user)
|
||||||
# Así que no necesitas validar manualmente los permisos aquí.
|
if not request.user.is_superuser and user.organizacion != org:
|
||||||
|
raise Http404("No autorizado")
|
||||||
|
|
||||||
if not user.profile_picture:
|
if not user.profile_picture:
|
||||||
raise Http404("El usuario no tiene imagen de perfil")
|
raise Http404("El usuario no tiene imagen de perfil")
|
||||||
@@ -267,6 +257,8 @@ class PasswordResetConfirmView(APIView):
|
|||||||
return Response({'detail': 'Enlace inválido.'}, status=400)
|
return Response({'detail': 'Enlace inválido.'}, status=400)
|
||||||
if not default_token_generator.check_token(user, token):
|
if not default_token_generator.check_token(user, token):
|
||||||
return Response({'detail': 'Token inválido o expirado.'}, status=400)
|
return Response({'detail': 'Token inválido o expirado.'}, status=400)
|
||||||
|
if not user.is_active:
|
||||||
|
return Response({'detail': 'La cuenta de usuario no está activa.'}, status=400)
|
||||||
password = request.data.get('password')
|
password = request.data.get('password')
|
||||||
if not password:
|
if not password:
|
||||||
return Response({'detail': 'La nueva contraseña es requerida.'}, status=400)
|
return Response({'detail': 'La nueva contraseña es requerida.'}, status=400)
|
||||||
|
|||||||
94
api/customs/management/commands/backfill_document_links.py
Normal file
94
api/customs/management/commands/backfill_document_links.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
"""
|
||||||
|
Backfill de la FK de sub-entidad en documentos existentes (T2025-09-004).
|
||||||
|
|
||||||
|
Liga cada Document a su Partida / Cove / EDocument por nombre de archivo, usando el
|
||||||
|
mismo resolver que `Document.save()` (core.document_links). Solo toca documentos aún
|
||||||
|
no ligados (idempotente: re-ejecutar converge). Los documentos nativos del pedimento
|
||||||
|
(PC, remesa, subidas generales) y los que no matchean ninguna entidad se dejan sin FK.
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
python manage.py backfill_document_links [--pedimento UUID] [--organizacion UUID]
|
||||||
|
[--offset N] [--limit N] [--dry-run]
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from api.customs.models import Pedimento
|
||||||
|
from api.record.models import Document
|
||||||
|
from core.document_links import SECCION_CAMPO, match_entidad, seccion_de_tipo
|
||||||
|
|
||||||
|
# related_name del FK a Pedimento en cada entidad (EDocument usa 'documentos').
|
||||||
|
_RELACION = {'partida': 'partidas', 'cove': 'coves', 'edocument': 'documentos'}
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Liga documentos existentes a su sub-entidad (partida/cove/edocument) por nombre de archivo."
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument('--pedimento', type=str, default=None, help='UUID de un solo pedimento')
|
||||||
|
parser.add_argument('--organizacion', type=str, default=None, help='UUID de organización')
|
||||||
|
parser.add_argument('--offset', type=int, default=0)
|
||||||
|
parser.add_argument('--limit', type=int, default=None)
|
||||||
|
parser.add_argument('--dry-run', action='store_true', help='Reporta sin escribir')
|
||||||
|
|
||||||
|
def handle(self, *args, **opts):
|
||||||
|
dry_run = opts['dry_run']
|
||||||
|
peds = Pedimento.objects.all().order_by('created_at', 'id')
|
||||||
|
if opts['pedimento']:
|
||||||
|
peds = peds.filter(id=opts['pedimento'])
|
||||||
|
if opts['organizacion']:
|
||||||
|
peds = peds.filter(organizacion_id=opts['organizacion'])
|
||||||
|
|
||||||
|
offset = opts['offset'] or 0
|
||||||
|
peds = peds[offset:offset + opts['limit']] if opts['limit'] else peds[offset:]
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
self.stdout.write(self.style.WARNING("== DRY-RUN: no se escribe nada =="))
|
||||||
|
|
||||||
|
stats = {'pedimentos': 0, 'partida': 0, 'cove': 0, 'edocument': 0, 'sin_match': 0}
|
||||||
|
for ped in peds.iterator():
|
||||||
|
self._procesar_pedimento(ped, dry_run, stats)
|
||||||
|
|
||||||
|
self.stdout.write("")
|
||||||
|
self.stdout.write(self.style.SUCCESS(
|
||||||
|
f"Pedimentos: {stats['pedimentos']} | ligados → partida={stats['partida']} "
|
||||||
|
f"cove={stats['cove']} edocument={stats['edocument']} | "
|
||||||
|
f"tipados sin entidad que matchee={stats['sin_match']}"
|
||||||
|
))
|
||||||
|
|
||||||
|
def _procesar_pedimento(self, ped, dry_run, stats):
|
||||||
|
# Solo documentos aún NO ligados (idempotente).
|
||||||
|
docs = list(Document.objects.filter(
|
||||||
|
pedimento=ped, partida__isnull=True, cove__isnull=True, edocument__isnull=True,
|
||||||
|
))
|
||||||
|
if not docs:
|
||||||
|
return
|
||||||
|
stats['pedimentos'] += 1
|
||||||
|
|
||||||
|
# Precargar las entidades del pedimento una sola vez; el match es en memoria.
|
||||||
|
entidades = {sec: list(getattr(ped, rel).all()) for sec, rel in _RELACION.items()}
|
||||||
|
app = ped.pedimento_app
|
||||||
|
|
||||||
|
lote = []
|
||||||
|
for doc in docs:
|
||||||
|
seccion = seccion_de_tipo(doc.document_type_id)
|
||||||
|
if not seccion or not doc.archivo:
|
||||||
|
continue # nativo de pedimento o sin archivo
|
||||||
|
inst = match_entidad(doc.archivo.name, seccion, app, entidades[seccion])
|
||||||
|
if inst is None:
|
||||||
|
stats['sin_match'] += 1
|
||||||
|
continue
|
||||||
|
setattr(doc, SECCION_CAMPO[seccion], inst)
|
||||||
|
lote.append(doc)
|
||||||
|
stats[seccion] += 1
|
||||||
|
|
||||||
|
# SELECT + COUNT previo antes de escribir (estándar de la organización).
|
||||||
|
self.stdout.write(
|
||||||
|
f" {ped.pedimento_app}: {len(lote)} de {len(docs)} doc(s) sin ligar serán ligados"
|
||||||
|
)
|
||||||
|
if lote and not dry_run:
|
||||||
|
with transaction.atomic():
|
||||||
|
Document.objects.bulk_update(
|
||||||
|
lote, ['partida', 'cove', 'edocument'], batch_size=1000
|
||||||
|
)
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""
|
||||||
|
Backfill LEGADO de la FK de cove/edocument por número único (T2025-09-004).
|
||||||
|
|
||||||
|
Para documentos viejos cuyo nombre quedó con una nomenclatura olvidada —otro
|
||||||
|
`pedimento_app` y/u otro prefijo (p.ej. `vu_EDC_0201_800_..._04382515ZIFF5_hex.pdf`
|
||||||
|
en un pedimento cuyo `pedimento_app` es `25-80-3452-5000586`)— el matcher estricto
|
||||||
|
de `backfill_document_links` no liga porque arma `vu_ed_{pedimento_app}_{numero}`.
|
||||||
|
Pero el **número de cove/edoc** (único y largo) sí está en el nombre y la entidad
|
||||||
|
existe.
|
||||||
|
|
||||||
|
Este comando, SOLO para cove y edocument (llaves únicas; partida queda fuera por ser
|
||||||
|
enteros cortos con colisiones, y los nativos no tienen entidad), liga la FK buscando
|
||||||
|
el `numero_cove`/`numero_edocument` como token en el nombre, sin exigir app ni prefijo.
|
||||||
|
|
||||||
|
Correr DESPUÉS de `backfill_document_links` (solo toca lo que quedó sin ligar).
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
python manage.py backfill_document_links_legacy [--pedimento UUID] [--organizacion UUID] [--dry-run]
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from api.customs.models import Cove, EDocument
|
||||||
|
from api.record.models import Document
|
||||||
|
from core.document_links import (
|
||||||
|
COVE_TYPES, EDOCUMENT_TYPES, SECCION_CAMPO, SECCION_LLAVE,
|
||||||
|
numero_en_nombre, seccion_de_tipo,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Liga documentos legados de cove/edoc (FK nula) por su número único en el nombre, ignorando app/prefijo viejos. Correr después de backfill_document_links."
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument('--pedimento', type=str, default=None)
|
||||||
|
parser.add_argument('--organizacion', type=str, default=None)
|
||||||
|
parser.add_argument('--dry-run', action='store_true', help='Reporta sin escribir')
|
||||||
|
|
||||||
|
def handle(self, *args, **opts):
|
||||||
|
dry_run = opts['dry_run']
|
||||||
|
tipos = sorted(COVE_TYPES | EDOCUMENT_TYPES)
|
||||||
|
base = Document.objects.filter(
|
||||||
|
partida__isnull=True, cove__isnull=True, edocument__isnull=True,
|
||||||
|
document_type_id__in=tipos,
|
||||||
|
)
|
||||||
|
if opts['pedimento']:
|
||||||
|
base = base.filter(pedimento_id=opts['pedimento'])
|
||||||
|
if opts['organizacion']:
|
||||||
|
base = base.filter(pedimento__organizacion_id=opts['organizacion'])
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
self.stdout.write(self.style.WARNING("== DRY-RUN: no se escribe nada =="))
|
||||||
|
|
||||||
|
# order_by() limpia el ordering por defecto del modelo (created_at), que si
|
||||||
|
# no se quita se cuela en el DISTINCT y duplica los pedimento_id.
|
||||||
|
ped_ids = list(base.order_by().values_list('pedimento_id', flat=True).distinct())
|
||||||
|
stats = {'pedimentos': 0, 'cove': 0, 'edocument': 0, 'ambiguos': 0, 'sin_match': 0}
|
||||||
|
|
||||||
|
for ped_id in ped_ids:
|
||||||
|
docs = list(base.filter(pedimento_id=ped_id))
|
||||||
|
entidades = {
|
||||||
|
'cove': list(Cove.objects.filter(pedimento_id=ped_id)),
|
||||||
|
'edocument': list(EDocument.objects.filter(pedimento_id=ped_id)),
|
||||||
|
}
|
||||||
|
lote = []
|
||||||
|
for doc in docs:
|
||||||
|
seccion = seccion_de_tipo(doc.document_type_id)
|
||||||
|
llave = SECCION_LLAVE[seccion]
|
||||||
|
matches = [
|
||||||
|
e for e in entidades[seccion]
|
||||||
|
if numero_en_nombre(doc.archivo.name, getattr(e, llave))
|
||||||
|
]
|
||||||
|
if len(matches) == 1:
|
||||||
|
setattr(doc, SECCION_CAMPO[seccion], matches[0])
|
||||||
|
lote.append(doc)
|
||||||
|
stats[seccion] += 1
|
||||||
|
elif len(matches) > 1:
|
||||||
|
stats['ambiguos'] += 1 # número ambiguo: se deja sin ligar
|
||||||
|
else:
|
||||||
|
stats['sin_match'] += 1 # ni por número aparece la entidad
|
||||||
|
|
||||||
|
if lote:
|
||||||
|
stats['pedimentos'] += 1
|
||||||
|
self.stdout.write(" %s: %d doc(s) ligados por número" % (str(ped_id)[:8], len(lote)))
|
||||||
|
if not dry_run:
|
||||||
|
with transaction.atomic():
|
||||||
|
Document.objects.bulk_update(lote, ['cove', 'edocument'], batch_size=500)
|
||||||
|
|
||||||
|
self.stdout.write("")
|
||||||
|
self.stdout.write(self.style.SUCCESS(
|
||||||
|
"Pedimentos: %d | ligados → cove=%d edocument=%d | ambiguos: %d | sin match ni por número: %d"
|
||||||
|
% (stats['pedimentos'], stats['cove'], stats['edocument'], stats['ambiguos'], stats['sin_match'])
|
||||||
|
))
|
||||||
138
api/customs/management/commands/dedup_documents.py
Normal file
138
api/customs/management/commands/dedup_documents.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
"""
|
||||||
|
Limpieza de documentos duplicados legados (T2025-09-004).
|
||||||
|
|
||||||
|
Un mismo documento (misma sub-entidad + mismo document_type) quedó con varias
|
||||||
|
filas porque, antes del fix de reemplazo (microservicio, jun-2026), cada descarga
|
||||||
|
re-creaba en vez de reemplazar. La creación ya está corregida; esto solo limpia lo
|
||||||
|
viejo.
|
||||||
|
|
||||||
|
Estrategia: encuentra los grupos duplicados con UNA agregación global por FK
|
||||||
|
(no itera pedimento por pedimento — hay ~110k pedimentos y casi ninguno tiene
|
||||||
|
duplicados). Por cada grupo (FK partida/cove/edocument + document_type, con >1
|
||||||
|
fila): conserva el MÁS RECIENTE cuyo archivo exista en storage (si ninguno existe,
|
||||||
|
conserva el más reciente y NO borra el grupo entero) y elimina el resto —archivo
|
||||||
|
en MinIO (si no lo referencia otra fila) + fila + ajuste de cuota vía Document.delete().
|
||||||
|
|
||||||
|
Solo toca documentos ligados a una entidad (partida/cove/edoc). NO toca documentos
|
||||||
|
nativos del pedimento ni subidas sin FK.
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
python manage.py dedup_documents [--pedimento UUID] [--organizacion UUID]
|
||||||
|
[--offset N] [--limit N] [--dry-run]
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db import transaction
|
||||||
|
from django.db.models import Count
|
||||||
|
|
||||||
|
from api.record.models import Document
|
||||||
|
from api.utils.storage_service import storage_service
|
||||||
|
|
||||||
|
# Campos FK de sub-entidad sobre los que se detectan duplicados.
|
||||||
|
_CAMPOS_FK = ('partida_id', 'cove_id', 'edocument_id')
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Elimina documentos duplicados legados (misma entidad + mismo tipo), conservando el más reciente con archivo válido."
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument('--pedimento', type=str, default=None)
|
||||||
|
parser.add_argument('--organizacion', type=str, default=None)
|
||||||
|
parser.add_argument('--offset', type=int, default=0, help='Saltar los primeros N grupos')
|
||||||
|
parser.add_argument('--limit', type=int, default=None, help='Procesar máximo N grupos')
|
||||||
|
parser.add_argument('--dry-run', action='store_true', help='Reporta sin borrar')
|
||||||
|
|
||||||
|
def handle(self, *args, **opts):
|
||||||
|
dry_run = opts['dry_run']
|
||||||
|
base = Document.objects.all()
|
||||||
|
if opts['pedimento']:
|
||||||
|
base = base.filter(pedimento_id=opts['pedimento'])
|
||||||
|
if opts['organizacion']:
|
||||||
|
base = base.filter(pedimento__organizacion_id=opts['organizacion'])
|
||||||
|
|
||||||
|
offset = opts['offset'] or 0
|
||||||
|
limit = opts['limit']
|
||||||
|
if dry_run:
|
||||||
|
self.stdout.write(self.style.WARNING("== DRY-RUN: no se borra nada =="))
|
||||||
|
|
||||||
|
stats = {'grupos': 0, 'eliminados': 0, 'bytes': 0, 'sin_archivo_valido': 0}
|
||||||
|
visto = 0 # índice global de grupos (para offset/limit)
|
||||||
|
for campo in _CAMPOS_FK:
|
||||||
|
# Una sola agregación: todos los grupos duplicados de esta FK.
|
||||||
|
grupos = (
|
||||||
|
base.filter(**{campo + '__isnull': False})
|
||||||
|
.values('pedimento_id', campo, 'document_type_id')
|
||||||
|
.annotate(n=Count('id'))
|
||||||
|
.filter(n__gt=1)
|
||||||
|
.order_by() # limpia el ordering por defecto del modelo (rompe el GROUP BY)
|
||||||
|
)
|
||||||
|
for g in grupos.iterator():
|
||||||
|
if visto < offset:
|
||||||
|
visto += 1
|
||||||
|
continue
|
||||||
|
if limit is not None and stats['grupos'] >= limit:
|
||||||
|
break
|
||||||
|
visto += 1
|
||||||
|
self._dedup_grupo(campo, g, dry_run, stats)
|
||||||
|
if limit is not None and stats['grupos'] >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
mb = stats['bytes'] / (1024 * 1024)
|
||||||
|
self.stdout.write("")
|
||||||
|
self.stdout.write(self.style.SUCCESS(
|
||||||
|
"Grupos con duplicados: %d | filas eliminadas: %d | espacio liberado: %.1f MB | grupos sin archivo válido (se conservó el más reciente): %d"
|
||||||
|
% (stats['grupos'], stats['eliminados'], mb, stats['sin_archivo_valido'])
|
||||||
|
))
|
||||||
|
|
||||||
|
def _dedup_grupo(self, campo, g, dry_run, stats):
|
||||||
|
docs = list(
|
||||||
|
Document.objects.filter(
|
||||||
|
pedimento_id=g['pedimento_id'],
|
||||||
|
document_type_id=g['document_type_id'],
|
||||||
|
**{campo: g[campo]},
|
||||||
|
).select_related('pedimento').order_by('-created_at')
|
||||||
|
)
|
||||||
|
if len(docs) < 2:
|
||||||
|
return
|
||||||
|
conservado = self._elegir_conservado(docs, dry_run, stats)
|
||||||
|
a_borrar = [d for d in docs if d.id != conservado.id]
|
||||||
|
if not a_borrar:
|
||||||
|
return
|
||||||
|
stats['grupos'] += 1
|
||||||
|
# SELECT + COUNT previo (estándar de la organización): reportar antes de borrar.
|
||||||
|
self.stdout.write(
|
||||||
|
" ped=%s %s=%s type=%s: conservar %s, eliminar %d (%s)"
|
||||||
|
% (str(g['pedimento_id'])[:8], campo, g[campo], g['document_type_id'],
|
||||||
|
str(conservado.id)[:8], len(a_borrar), ', '.join(str(d.id)[:8] for d in a_borrar))
|
||||||
|
)
|
||||||
|
if not dry_run:
|
||||||
|
for d in a_borrar:
|
||||||
|
self._borrar(d, stats)
|
||||||
|
|
||||||
|
def _elegir_conservado(self, docs_desc, dry_run, stats):
|
||||||
|
"""docs_desc viene ordenado por -created_at. En dry-run conserva el más
|
||||||
|
reciente sin tocar storage; en ejecución real, el más reciente cuyo
|
||||||
|
archivo exista en MinIO (fallback: el más reciente, para no borrar todo)."""
|
||||||
|
if dry_run:
|
||||||
|
return docs_desc[0]
|
||||||
|
for d in docs_desc:
|
||||||
|
try:
|
||||||
|
if d.archivo and storage_service.file_exists(d.archivo.name):
|
||||||
|
return d
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
stats['sin_archivo_valido'] += 1
|
||||||
|
return docs_desc[0]
|
||||||
|
|
||||||
|
def _borrar(self, doc, stats):
|
||||||
|
with transaction.atomic():
|
||||||
|
# Borrar el archivo en MinIO solo si ninguna OTRA fila lo referencia.
|
||||||
|
nombre = doc.archivo.name if doc.archivo else None
|
||||||
|
if nombre and not Document.objects.filter(archivo=nombre).exclude(id=doc.id).exists():
|
||||||
|
try:
|
||||||
|
storage_service.delete_file(nombre)
|
||||||
|
except Exception as e:
|
||||||
|
self.stdout.write(self.style.WARNING(f" no se pudo borrar de storage {nombre}: {e}"))
|
||||||
|
stats['bytes'] += doc.size or 0
|
||||||
|
doc.delete() # ajusta la cuota de almacenamiento (UsoAlmacenamiento)
|
||||||
|
stats['eliminados'] += 1
|
||||||
117
api/customs/management/commands/fix_archivo_case.py
Normal file
117
api/customs/management/commands/fix_archivo_case.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"""
|
||||||
|
Corrige el mismatch de case entre el campo `archivo` en BD y los nombres
|
||||||
|
reales de los objetos en MinIO.
|
||||||
|
|
||||||
|
Causa habitual: transferencia de archivos de producción a local lowercaseó
|
||||||
|
los filenames, pero la BD conserva los nombres originales con mayúsculas.
|
||||||
|
|
||||||
|
Estrategia: para cada Document cuyo `archivo` no exista en MinIO con el
|
||||||
|
nombre exacto, intenta el filename en minúsculas. Si lo encuentra, actualiza
|
||||||
|
el campo en BD. Los archivos que ya coinciden no se tocan.
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
python manage.py fix_archivo_case --pedimento <UUID> --dry-run
|
||||||
|
python manage.py fix_archivo_case --pedimento <UUID>
|
||||||
|
python manage.py fix_archivo_case --organizacion <UUID> --dry-run
|
||||||
|
python manage.py fix_archivo_case --organizacion <UUID>
|
||||||
|
"""
|
||||||
|
import posixpath
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
|
||||||
|
from api.customs.models import Pedimento
|
||||||
|
from api.record.models import Document
|
||||||
|
from api.utils.minio_client import minio_client
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Corrige mismatch de case entre campo archivo en BD y MinIO."
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"--pedimento", metavar="UUID",
|
||||||
|
help="UUID del pedimento a corregir.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--organizacion", metavar="UUID",
|
||||||
|
help="UUID de la organización.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run", action="store_true",
|
||||||
|
help="Solo diagnóstico, sin aplicar cambios.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
ped_id = options.get("pedimento")
|
||||||
|
org_id = options.get("organizacion")
|
||||||
|
dry_run = options["dry_run"]
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
self.stdout.write(self.style.WARNING(
|
||||||
|
"=== MODO PRUEBA (--dry-run): Sin cambios en BD ===\n"
|
||||||
|
))
|
||||||
|
|
||||||
|
qs = Document.objects.all()
|
||||||
|
if ped_id:
|
||||||
|
try:
|
||||||
|
ped = Pedimento.objects.get(id=ped_id)
|
||||||
|
except Pedimento.DoesNotExist:
|
||||||
|
raise CommandError(f"Pedimento {ped_id!r} no encontrado.")
|
||||||
|
qs = qs.filter(pedimento=ped)
|
||||||
|
self.stdout.write(f"Pedimento: {ped.pedimento_app}\n")
|
||||||
|
elif org_id:
|
||||||
|
qs = qs.filter(organizacion_id=org_id)
|
||||||
|
|
||||||
|
total = qs.count()
|
||||||
|
self.stdout.write(f"Documentos a revisar: {total}\n")
|
||||||
|
|
||||||
|
ok = mismatch = not_found = 0
|
||||||
|
|
||||||
|
for doc in qs.iterator(chunk_size=500):
|
||||||
|
name = doc.archivo.name if doc.archivo else None
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if minio_client.file_exists(name):
|
||||||
|
ok += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
lower_name = self._lower_filename(name)
|
||||||
|
if lower_name == name:
|
||||||
|
not_found += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if minio_client.file_exists(lower_name):
|
||||||
|
mismatch += 1
|
||||||
|
self.stdout.write(
|
||||||
|
f" {'[DRY]' if dry_run else '[FIX]'} doc {doc.id}:\n"
|
||||||
|
f" BD : {name}\n"
|
||||||
|
f" MinIO : {lower_name}\n"
|
||||||
|
)
|
||||||
|
if not dry_run:
|
||||||
|
doc.archivo.name = lower_name
|
||||||
|
doc.save(update_fields=["archivo"])
|
||||||
|
else:
|
||||||
|
not_found += 1
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
f"\n{'─' * 60}\nRESUMEN\n"
|
||||||
|
f" Coinciden exacto : {ok}\n"
|
||||||
|
f" Mismatch de case : {mismatch}\n"
|
||||||
|
f" No encontrados : {not_found}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
if dry_run and mismatch:
|
||||||
|
self.stdout.write(self.style.WARNING(
|
||||||
|
"\nEjecuta sin --dry-run para aplicar los cambios."
|
||||||
|
))
|
||||||
|
elif not dry_run and mismatch:
|
||||||
|
self.stdout.write(self.style.SUCCESS(
|
||||||
|
f"\n{mismatch} registros actualizados en BD."
|
||||||
|
))
|
||||||
|
|
||||||
|
def _lower_filename(self, name):
|
||||||
|
"""Lowercase solo el filename, preserva el path del directorio."""
|
||||||
|
dir_part = posixpath.dirname(name)
|
||||||
|
filename = posixpath.basename(name)
|
||||||
|
return posixpath.join(dir_part, filename.lower())
|
||||||
530
api/customs/management/commands/fix_partidas_error.py
Normal file
530
api/customs/management/commands/fix_partidas_error.py
Normal file
@@ -0,0 +1,530 @@
|
|||||||
|
"""
|
||||||
|
Diagnóstico y corrección de partidas con descargado=True que NO tienen un XML
|
||||||
|
de respuesta de partida válido.
|
||||||
|
|
||||||
|
Una partida cuenta como realmente descargada solo si alguno de sus documentos
|
||||||
|
contiene el nodo <consultarPartidaRespuesta> sin <tieneError>true</tieneError>.
|
||||||
|
|
||||||
|
Clasificación por contenido de cada documento candidato (excluye types 17/18,
|
||||||
|
que ya están identificados como REQUEST/ERROR):
|
||||||
|
- valida : consultarPartidaRespuesta sin tieneError=true
|
||||||
|
- error : tieneError=true → renombra a _ERROR, type 18
|
||||||
|
- request : consultarPartidaPeticion → renombra a _REQUEST, type 17
|
||||||
|
(eco de la petición guardado como si fuera respuesta)
|
||||||
|
- desconocido : contenido no identificable → solo reporte
|
||||||
|
- ausente : registro en BD cuyo archivo no existe en storage
|
||||||
|
- no_verificable : storage inaccesible (excepción al consultar/leer)
|
||||||
|
|
||||||
|
Veredicto por partida con descargado=True:
|
||||||
|
- ≥1 valida → conserva descargado=True
|
||||||
|
- 0 validas y ≥1 no_verificable → sin cambios (storage inaccesible)
|
||||||
|
- 0 validas, ≥1 ausente y NINGÚN archivo del pedimento existe en storage
|
||||||
|
→ sin cambios (canario: probablemente se
|
||||||
|
está corriendo contra un storage que no
|
||||||
|
es el de esta BD, p. ej. dev)
|
||||||
|
- en cualquier otro caso → descargado=False (incluye partidas que
|
||||||
|
solo tienen el REQUEST, ningún doc, o
|
||||||
|
registros fantasma con el storage real)
|
||||||
|
|
||||||
|
Canario de storage: si al menos un archivo vu_PT_ del pedimento (REQUEST,
|
||||||
|
ERROR o respuesta) sí existe en storage, el storage es el correcto y los
|
||||||
|
documentos ausentes son registros fantasma reales (BD sin archivo).
|
||||||
|
|
||||||
|
Convenciones de nomenclatura del microservicio:
|
||||||
|
- REQUEST (type 17): vu_PT_{pedimento_app}_{partida}_REQUEST.xml
|
||||||
|
- ERROR (type 18): vu_PT_{pedimento_app}_{partida}_ERROR.xml
|
||||||
|
- Éxito (type 1): vu_PT_{pedimento_app}_{partida}.xml
|
||||||
|
(el storage puede agregar sufijos de unicidad: vu_PT_{...}_{partida}_Ab12xQ.xml)
|
||||||
|
- Legacy : vu_PT_..._{partida}.xml (número de partida al final)
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
python manage.py fix_partidas_error --pedimento <UUID> --dry-run
|
||||||
|
python manage.py fix_partidas_error --organizacion <UUID> --dry-run
|
||||||
|
python manage.py fix_partidas_error --organizacion <UUID>
|
||||||
|
python manage.py fix_partidas_error --solo-malformados --dry-run
|
||||||
|
python manage.py fix_partidas_error --dry-run # todas las orgs
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
import posixpath
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.db import transaction
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.db.models.functions import Length
|
||||||
|
|
||||||
|
from api.customs.models import Partida, Pedimento
|
||||||
|
from api.record.models import Document
|
||||||
|
from api.utils.minio_client import minio_client
|
||||||
|
from core.partida_docs import es_doc_de_partida
|
||||||
|
|
||||||
|
_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 = "Corrige partidas descargado=True sin XML de respuesta de partida válido."
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"--organizacion", metavar="UUID",
|
||||||
|
help="UUID de la organización. Sin este arg: todas las orgs.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--pedimento", metavar="UUID",
|
||||||
|
help="UUID del pedimento a diagnosticar/corregir.",
|
||||||
|
)
|
||||||
|
# Filtros de fecha (aplican sobre fecha_pago del pedimento)
|
||||||
|
parser.add_argument(
|
||||||
|
"--fecha-desde", metavar="YYYY-MM-DD",
|
||||||
|
help="Procesar pedimentos con fecha_pago >= esta fecha.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--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 (default: 0).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--limit", type=int, default=0,
|
||||||
|
help="Procesar máximo N pedimentos (default: 0 = todos).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run", action="store_true",
|
||||||
|
help="Solo diagnóstico, sin aplicar cambios.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Entry point
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
org_id = options.get("organizacion")
|
||||||
|
ped_id = options.get("pedimento")
|
||||||
|
fecha_desde = options.get("fecha_desde")
|
||||||
|
fecha_hasta = options.get("fecha_hasta")
|
||||||
|
offset = options["offset"]
|
||||||
|
limit = options["limit"]
|
||||||
|
dry_run = options["dry_run"]
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
self.stdout.write(self.style.WARNING(
|
||||||
|
"=== MODO PRUEBA (--dry-run): Sin cambios en BD ni storage ===\n"
|
||||||
|
))
|
||||||
|
|
||||||
|
if ped_id:
|
||||||
|
self._handle_single(ped_id, dry_run)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
if fecha_desde:
|
||||||
|
ped_qs = ped_qs.filter(fecha_pago__gte=fecha_desde)
|
||||||
|
if fecha_hasta:
|
||||||
|
ped_qs = ped_qs.filter(fecha_pago__lte=fecha_hasta)
|
||||||
|
|
||||||
|
ped_qs = ped_qs.select_related("organizacion").order_by("fecha_pago", "pedimento_app")
|
||||||
|
|
||||||
|
total_sin_filtro = ped_qs.count()
|
||||||
|
|
||||||
|
if offset:
|
||||||
|
ped_qs = ped_qs[offset:]
|
||||||
|
if limit:
|
||||||
|
ped_qs = ped_qs[:limit]
|
||||||
|
|
||||||
|
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 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 revisar en este lote."))
|
||||||
|
return
|
||||||
|
|
||||||
|
stats = self._stats_vacios()
|
||||||
|
n_peds = 0
|
||||||
|
for ped in ped_qs:
|
||||||
|
parciales = self._process_pedimento(ped, dry_run)
|
||||||
|
n_peds += 1
|
||||||
|
for k in stats:
|
||||||
|
stats[k] += parciales[k]
|
||||||
|
|
||||||
|
self._print_summary(n_peds, stats, dry_run)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Flujo --pedimento
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def _handle_single(self, ped_id, dry_run):
|
||||||
|
try:
|
||||||
|
ped = Pedimento.objects.get(id=ped_id)
|
||||||
|
except Pedimento.DoesNotExist:
|
||||||
|
raise CommandError(f"Pedimento {ped_id!r} no encontrado.")
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def _malformed_qs(self):
|
||||||
|
return Pedimento.objects.annotate(
|
||||||
|
aduana_len=Length("aduana"),
|
||||||
|
patente_len=Length("patente"),
|
||||||
|
pedimento_len=Length("pedimento"),
|
||||||
|
).filter(
|
||||||
|
Q(aduana__isnull=True) | Q(aduana="") | Q(aduana_len__lt=3)
|
||||||
|
| Q(numero_operacion__isnull=True) | Q(numero_operacion="")
|
||||||
|
| Q(patente__isnull=True) | Q(patente="") | Q(patente_len__lt=4)
|
||||||
|
| Q(pedimento__isnull=True) | Q(pedimento="") | Q(pedimento_len__lt=7)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Diagnóstico de un pedimento
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def _field_checks(self, ped):
|
||||||
|
return {
|
||||||
|
"aduana (debe tener 3 dígitos)": not ped.aduana or len(ped.aduana.strip()) < 3,
|
||||||
|
"numero_operacion (obligatorio)": not ped.numero_operacion or not ped.numero_operacion.strip(),
|
||||||
|
"patente (debe tener 4 dígitos)": not ped.patente or len(ped.patente.strip()) < 4,
|
||||||
|
"pedimento_fld (debe tener 7 dígitos)": not ped.pedimento or len(ped.pedimento.strip()) < 7,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _print_ped_diagnosis(self, ped, checks):
|
||||||
|
es_malo = any(checks.values())
|
||||||
|
estado = self.style.ERROR("MALFORMADO") if es_malo else self.style.SUCCESS("VÁLIDO")
|
||||||
|
self.stdout.write(
|
||||||
|
f"Pedimento {ped.pedimento_app} (id={ped.id}) → {estado}\n"
|
||||||
|
f" aduana = {ped.aduana!r} (len={len(ped.aduana or '')})\n"
|
||||||
|
f" patente = {ped.patente!r} (len={len(ped.patente or '')})\n"
|
||||||
|
f" numero_op = {ped.numero_operacion!r}\n"
|
||||||
|
f" pedimento_fld = {ped.pedimento!r} (len={len(ped.pedimento or '')})\n"
|
||||||
|
)
|
||||||
|
for campo, malo in checks.items():
|
||||||
|
marca = self.style.ERROR("✗") if malo else self.style.SUCCESS("✓")
|
||||||
|
self.stdout.write(f" {marca} {campo}")
|
||||||
|
self.stdout.write("")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# 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 stats
|
||||||
|
|
||||||
|
self.stdout.write(f" Partidas con descargado=True: {n_partidas}")
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
self._process_partida(ped, partida, docs_pedimento, storage_es_correcto, dry_run, stats)
|
||||||
|
|
||||||
|
self.stdout.write("")
|
||||||
|
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
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Procesamiento de una partida
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
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):
|
||||||
|
"""
|
||||||
|
Asigna documentos a una partida por nombre de archivo. La regla (frontera
|
||||||
|
_/./fin de cadena + formato legacy) vive en core.partida_docs como fuente
|
||||||
|
única, compartida con el serializer y los handlers de borrado/descarga.
|
||||||
|
"""
|
||||||
|
asignados = {}
|
||||||
|
for doc in docs:
|
||||||
|
if es_doc_de_partida(doc.archivo.name, pedimento_app, numero_partida):
|
||||||
|
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:
|
||||||
|
if not minio_client.file_exists(name):
|
||||||
|
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").lower()
|
||||||
|
except Exception as e:
|
||||||
|
return _NO_VERIFICABLE, f"excepción al leer archivo: {e}"
|
||||||
|
|
||||||
|
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, corregibles, marcar_no_descargada, pedimento_app):
|
||||||
|
"""
|
||||||
|
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 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 = 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={doc_type} | {final_name}"
|
||||||
|
))
|
||||||
|
|
||||||
|
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:
|
||||||
|
return old_name
|
||||||
|
|
||||||
|
if minio_client.file_exists(new_name):
|
||||||
|
# Rename ya ocurrió en ejecución previa parcial
|
||||||
|
self.stderr.write(self.style.WARNING(
|
||||||
|
f" ⚠ Destino ya existe en storage, usando: {new_name}"
|
||||||
|
))
|
||||||
|
if minio_client.file_exists(old_name):
|
||||||
|
minio_client.delete_file(old_name)
|
||||||
|
return new_name
|
||||||
|
|
||||||
|
if not minio_client.file_exists(old_name):
|
||||||
|
self.stderr.write(self.style.WARNING(
|
||||||
|
f" ⚠ Archivo no encontrado en storage: {old_name}"
|
||||||
|
))
|
||||||
|
return old_name
|
||||||
|
|
||||||
|
response = minio_client._client.get_object(minio_client._bucket_name, old_name)
|
||||||
|
try:
|
||||||
|
content = response.read()
|
||||||
|
finally:
|
||||||
|
response.close()
|
||||||
|
response.release_conn()
|
||||||
|
|
||||||
|
minio_client.upload_file(new_name, file_data=io.BytesIO(content), content_type="application/xml")
|
||||||
|
minio_client.delete_file(old_name)
|
||||||
|
return new_name
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Resumen final
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def _print_summary(self, total_peds, stats, dry_run):
|
||||||
|
self.stdout.write(
|
||||||
|
f"\n{'─' * 60}\nRESUMEN\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(
|
||||||
|
"\nMODO PRUEBA: ejecuta sin --dry-run para aplicar los cambios."
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
self.stdout.write(self.style.SUCCESS("\nCorrección completada."))
|
||||||
110
api/customs/management/commands/reconciliar_descargas.py
Normal file
110
api/customs/management/commands/reconciliar_descargas.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from api.customs.models import EDocument, Cove, EstadoDescarga
|
||||||
|
from api.record.models import Document
|
||||||
|
from api.utils.storage_service import storage_service
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
"""
|
||||||
|
Reconciliación de estatus de descarga VUCEM (T2026-05-027).
|
||||||
|
|
||||||
|
Detecta registros marcados como 'descargado' cuyo documento no existe en BD
|
||||||
|
o cuyo archivo falta físicamente en storage (MinIO), y los transiciona a
|
||||||
|
estado 'error' para que sean visibles y reprocesables. Sin --apply solo
|
||||||
|
reporta (dry-run).
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
python manage.py reconciliar_descargas # reporte
|
||||||
|
python manage.py reconciliar_descargas --apply # corrige
|
||||||
|
python manage.py reconciliar_descargas --organizacion <uuid>
|
||||||
|
"""
|
||||||
|
|
||||||
|
help = "Reconcilia estatus de descarga de EDocs/COVEs contra documentos reales (BD + storage)"
|
||||||
|
|
||||||
|
# Catálogo confirmado de document_type:
|
||||||
|
# 4 = acuse EDoc, 7 = acuse COVE, 19/23 = request COVE, 21/25 = request EDoc,
|
||||||
|
# 20 = error COVE, 22 = error EDoc, 24 = error acuse COVE, 26 = error acuse EDoc
|
||||||
|
EXCLUIR_EDOC_GENERAL = [4, 21, 22, 25, 26]
|
||||||
|
EXCLUIR_COVE_GENERAL = [7, 19, 20, 23, 24]
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
'--apply', action='store_true',
|
||||||
|
help='Aplica las correcciones; sin esta bandera solo reporta (dry-run)'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--organizacion', type=str, default=None,
|
||||||
|
help='Limitar la reconciliación a una organización (UUID)'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--pedimento', type=str, default=None,
|
||||||
|
help='Limitar la reconciliación a un pedimento (UUID)'
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **opts):
|
||||||
|
apply_changes = opts['apply']
|
||||||
|
detectados = []
|
||||||
|
|
||||||
|
flujos = [
|
||||||
|
# (modelo, campo_estado, campo_intentos, etiqueta, fn_documentos)
|
||||||
|
(EDocument, 'acuse_estado', 'acuse_intentos', 'edoc.acuse',
|
||||||
|
lambda r: Document.objects.filter(
|
||||||
|
pedimento=r.pedimento,
|
||||||
|
archivo__icontains=r.numero_edocument,
|
||||||
|
document_type_id=4)),
|
||||||
|
(EDocument, 'edocument_estado', 'edocument_intentos', 'edoc.general',
|
||||||
|
lambda r: Document.objects.filter(
|
||||||
|
pedimento=r.pedimento,
|
||||||
|
archivo__icontains=r.numero_edocument,
|
||||||
|
).exclude(document_type_id__in=self.EXCLUIR_EDOC_GENERAL)),
|
||||||
|
(Cove, 'acuse_cove_estado', 'acuse_cove_intentos', 'cove.acuse',
|
||||||
|
lambda r: Document.objects.filter(
|
||||||
|
pedimento=r.pedimento,
|
||||||
|
archivo__icontains=r.numero_cove,
|
||||||
|
document_type_id=7)),
|
||||||
|
(Cove, 'cove_estado', 'cove_intentos', 'cove.general',
|
||||||
|
lambda r: Document.objects.filter(
|
||||||
|
pedimento=r.pedimento,
|
||||||
|
archivo__icontains=r.numero_cove,
|
||||||
|
).exclude(document_type_id__in=self.EXCLUIR_COVE_GENERAL)),
|
||||||
|
]
|
||||||
|
|
||||||
|
for modelo, campo_estado, campo_intentos, etiqueta, fn_documentos in flujos:
|
||||||
|
qs = modelo.objects.filter(**{campo_estado: EstadoDescarga.DESCARGADO})
|
||||||
|
if opts['organizacion']:
|
||||||
|
qs = qs.filter(organizacion_id=opts['organizacion'])
|
||||||
|
if opts['pedimento']:
|
||||||
|
qs = qs.filter(pedimento_id=opts['pedimento'])
|
||||||
|
|
||||||
|
for registro in qs.select_related('pedimento').iterator():
|
||||||
|
numero = getattr(registro, 'numero_edocument', None) or registro.numero_cove
|
||||||
|
docs = fn_documentos(registro)
|
||||||
|
# Disponible = al menos un documento con fila en BD, tamaño > 0
|
||||||
|
# y archivo físicamente presente en storage
|
||||||
|
disponible = any(
|
||||||
|
doc.size and storage_service.file_exists(doc.archivo.name)
|
||||||
|
for doc in docs
|
||||||
|
)
|
||||||
|
if disponible:
|
||||||
|
continue
|
||||||
|
|
||||||
|
detectados.append((etiqueta, str(registro.id), numero, str(registro.pedimento_id)))
|
||||||
|
if apply_changes:
|
||||||
|
with transaction.atomic():
|
||||||
|
setattr(registro, campo_estado, EstadoDescarga.ERROR)
|
||||||
|
registro.ultimo_error = (
|
||||||
|
f"Reconciliación: {etiqueta} marcado como descargado "
|
||||||
|
f"sin archivo disponible en BD/storage"
|
||||||
|
)
|
||||||
|
# save() del modelo sincroniza el booleano legado
|
||||||
|
registro.save(update_fields=[campo_estado, 'ultimo_error'])
|
||||||
|
|
||||||
|
modo = 'CORREGIDOS' if apply_changes else 'DETECTADOS (dry-run, usa --apply para corregir)'
|
||||||
|
self.stdout.write(self.style.WARNING(f"{modo}: {len(detectados)}"))
|
||||||
|
for etiqueta, registro_id, numero, pedimento_id in detectados:
|
||||||
|
self.stdout.write(f" [{etiqueta}] id={registro_id} numero={numero} pedimento={pedimento_id}")
|
||||||
|
|
||||||
|
if not detectados:
|
||||||
|
self.stdout.write(self.style.SUCCESS("Sin inconsistencias: todos los 'descargado' tienen archivo disponible"))
|
||||||
50
api/customs/migrations/0017_bulkuploadtask.py
Normal file
50
api/customs/migrations/0017_bulkuploadtask.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Generated by Django 5.2.3 on 2026-01-16 00:36
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('customs', '0016_alter_pedimento_unique_together'),
|
||||||
|
('organization', '0002_remove_organizacion_membretado_and_more'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='BulkUploadTask',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('contribuyente', models.CharField(blank=True, max_length=255, null=True)),
|
||||||
|
('status', models.CharField(choices=[('pending', 'Pendiente'), ('processing', 'Procesando'), ('completed', 'Completado'), ('failed', 'Fallido'), ('partial', 'Parcialmente completado')], default='pending', max_length=20)),
|
||||||
|
('task_type', models.CharField(default='bulk_create', max_length=50)),
|
||||||
|
('total_files', models.IntegerField(default=0)),
|
||||||
|
('processed_files', models.IntegerField(default=0)),
|
||||||
|
('created_pedimentos', models.IntegerField(default=0)),
|
||||||
|
('created_documents', models.IntegerField(default=0)),
|
||||||
|
('result', models.JSONField(blank=True, default=dict)),
|
||||||
|
('failed_files', models.JSONField(blank=True, default=list)),
|
||||||
|
('error_message', models.TextField(blank=True, null=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('started_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('finished_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('fecha_pago', models.DateField(blank=True, null=True)),
|
||||||
|
('clave_pedimento', models.CharField(blank=True, max_length=50, null=True)),
|
||||||
|
('tipo_operacion_id', models.IntegerField(blank=True, null=True)),
|
||||||
|
('curp_apoderado', models.CharField(blank=True, max_length=50, null=True)),
|
||||||
|
('partidas', models.IntegerField(default=0)),
|
||||||
|
('celery_task_id', models.CharField(blank=True, max_length=255, null=True)),
|
||||||
|
('organizacion', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='organization.organizacion')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bulk_upload_tasks', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Tarea de Carga Masiva',
|
||||||
|
'verbose_name_plural': 'Tareas de Carga Masiva',
|
||||||
|
'db_table': 'bulk_upload_task',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Generated by Django 5.2.3 on 2026-03-06 19:35
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('customs', '0017_bulkuploadtask'),
|
||||||
|
('organization', '0002_remove_organizacion_membretado_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='pedimento',
|
||||||
|
unique_together={('organizacion', 'pedimento_app')},
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='BulkUploadTask',
|
||||||
|
),
|
||||||
|
]
|
||||||
18
api/customs/migrations/0019_pedimento_consultar_vucem.py
Normal file
18
api/customs/migrations/0019_pedimento_consultar_vucem.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.3 on 2026-05-19 14:24
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('customs', '0018_alter_pedimento_unique_together_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='pedimento',
|
||||||
|
name='consultar_vucem',
|
||||||
|
field=models.BooleanField(default=False, help_text='Solo pedimentos originados desde datastage deben consultar VUCEM automáticamente'),
|
||||||
|
),
|
||||||
|
]
|
||||||
99
api/customs/migrations/0020_estados_descarga_t2026_05_027.py
Normal file
99
api/customs/migrations/0020_estados_descarga_t2026_05_027.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# Migración T2026-05-027: estados de descarga de 3 valores (pendiente/descargado/error)
|
||||||
|
# y contador de intentos automáticos para EDocument y Cove.
|
||||||
|
#
|
||||||
|
# NO aplicar en automático. Después de aplicarla, ejecutar el backfill:
|
||||||
|
# backend/scripts/t2026_05_027/02_backfill_estados.sql
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('customs', '0019_pedimento_consultar_vucem'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# --- EDocument ---
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='edocument',
|
||||||
|
name='edocument_estado',
|
||||||
|
field=models.CharField(choices=[('pendiente', 'Pendiente'), ('descargado', 'Descargado'), ('error', 'Error')], default='pendiente', help_text='Estado de descarga del e-documento: pendiente, descargado o error', max_length=12),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='edocument',
|
||||||
|
name='acuse_estado',
|
||||||
|
field=models.CharField(choices=[('pendiente', 'Pendiente'), ('descargado', 'Descargado'), ('error', 'Error')], default='pendiente', help_text='Estado de descarga del acuse: pendiente, descargado o error', max_length=12),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='edocument',
|
||||||
|
name='edocument_intentos',
|
||||||
|
field=models.PositiveSmallIntegerField(default=0, help_text='Intentos automáticos de descarga del e-documento (un ciclo de orquestación = un intento)'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='edocument',
|
||||||
|
name='acuse_intentos',
|
||||||
|
field=models.PositiveSmallIntegerField(default=0, help_text='Intentos automáticos de descarga del acuse (un ciclo de orquestación = un intento)'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='edocument',
|
||||||
|
name='ultimo_intento_at',
|
||||||
|
field=models.DateTimeField(blank=True, help_text='Fecha del último intento automático de descarga', null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='edocument',
|
||||||
|
name='ultimo_error',
|
||||||
|
field=models.TextField(blank=True, help_text='Detalle del último error de descarga', null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='edocument',
|
||||||
|
name='edocument_descargado',
|
||||||
|
field=models.BooleanField(default=False, help_text='Indica si el e-documento ha sido descargado (legado, derivado de edocument_estado)'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='edocument',
|
||||||
|
name='acuse_descargado',
|
||||||
|
field=models.BooleanField(default=False, help_text='Indica si el acuse del e-documento ha sido descargado (legado, derivado de acuse_estado)'),
|
||||||
|
),
|
||||||
|
# --- Cove ---
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='cove',
|
||||||
|
name='cove_estado',
|
||||||
|
field=models.CharField(choices=[('pendiente', 'Pendiente'), ('descargado', 'Descargado'), ('error', 'Error')], default='pendiente', help_text='Estado de descarga de la cove: pendiente, descargado o error', max_length=12),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='cove',
|
||||||
|
name='acuse_cove_estado',
|
||||||
|
field=models.CharField(choices=[('pendiente', 'Pendiente'), ('descargado', 'Descargado'), ('error', 'Error')], default='pendiente', help_text='Estado de descarga del acuse de la cove: pendiente, descargado o error', max_length=12),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='cove',
|
||||||
|
name='cove_intentos',
|
||||||
|
field=models.PositiveSmallIntegerField(default=0, help_text='Intentos automáticos de descarga de la cove (un ciclo de orquestación = un intento)'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='cove',
|
||||||
|
name='acuse_cove_intentos',
|
||||||
|
field=models.PositiveSmallIntegerField(default=0, help_text='Intentos automáticos de descarga del acuse de la cove (un ciclo de orquestación = un intento)'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='cove',
|
||||||
|
name='ultimo_intento_at',
|
||||||
|
field=models.DateTimeField(blank=True, help_text='Fecha del último intento automático de descarga', null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='cove',
|
||||||
|
name='ultimo_error',
|
||||||
|
field=models.TextField(blank=True, help_text='Detalle del último error de descarga', null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='cove',
|
||||||
|
name='cove_descargado',
|
||||||
|
field=models.BooleanField(default=False, help_text='Indica si la cove ha sido descargada (legado, derivado de cove_estado)'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='cove',
|
||||||
|
name='acuse_cove_descargado',
|
||||||
|
field=models.BooleanField(default=False, help_text='Indica si el acuse de la cove ha sido descargado (legado, derivado de acuse_cove_estado)'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -34,6 +34,7 @@ class Pedimento(models.Model):
|
|||||||
fecha_pago = models.DateField(help_text="Fecha de pago del pedimento", blank=True, null=True)
|
fecha_pago = models.DateField(help_text="Fecha de pago del pedimento", blank=True, null=True)
|
||||||
|
|
||||||
alerta = models.BooleanField(default=False, help_text="Indica si el pedimento tiene una alerta asociada")
|
alerta = models.BooleanField(default=False, help_text="Indica si el pedimento tiene una alerta asociada")
|
||||||
|
consultar_vucem = models.BooleanField(default=False, help_text="Solo pedimentos originados desde datastage deben consultar VUCEM automáticamente")
|
||||||
|
|
||||||
contribuyente = models.ForeignKey('Importador', on_delete=models.CASCADE, related_name='pedimentos', help_text="Contribuyente asociado al pedimento", blank=True, null=True)
|
contribuyente = models.ForeignKey('Importador', on_delete=models.CASCADE, related_name='pedimentos', help_text="Contribuyente asociado al pedimento", blank=True, null=True)
|
||||||
agente_aduanal = models.CharField(max_length=100, blank=True, null=True, help_text="RFC del agente aduanal")
|
agente_aduanal = models.CharField(max_length=100, blank=True, null=True, help_text="RFC del agente aduanal")
|
||||||
@@ -65,6 +66,13 @@ class Pedimento(models.Model):
|
|||||||
['organizacion', 'pedimento_app']
|
['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):
|
class Partida(models.Model):
|
||||||
pedimento = models.ForeignKey(Pedimento, on_delete=models.CASCADE, related_name='partidas', help_text="Pedimento asociado a la partida")
|
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")
|
organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='partidas', help_text="Organización a la que pertenece la partida")
|
||||||
@@ -93,8 +101,28 @@ class EDocument(models.Model):
|
|||||||
descripcion = models.CharField(max_length=200, blank=True, null=True, help_text="Descripción del documento")
|
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")
|
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")
|
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")
|
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")
|
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):
|
def __str__(self):
|
||||||
return f"{self.descripcion} - {self.pedimento.pedimento}"
|
return f"{self.descripcion} - {self.pedimento.pedimento}"
|
||||||
@@ -111,8 +139,28 @@ class Cove(models.Model):
|
|||||||
numero_cove = models.CharField(max_length=20, unique=True, help_text="Número único de la cove")
|
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")
|
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")
|
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")
|
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")
|
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):
|
def __str__(self):
|
||||||
return f"{self.numero_cove} - {self.pedimento.pedimento}"
|
return f"{self.numero_cove} - {self.pedimento.pedimento}"
|
||||||
|
|||||||
@@ -6,13 +6,16 @@ from api.customs.models import (
|
|||||||
EDocument,
|
EDocument,
|
||||||
Cove,
|
Cove,
|
||||||
Importador,
|
Importador,
|
||||||
Partida
|
Partida,
|
||||||
|
EstadoDescarga
|
||||||
)
|
)
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from api.record.models import Document # Asegúrate de importar el modelo Documento
|
|
||||||
from api.record.serializers import DocumentSerializer
|
from api.record.serializers import DocumentSerializer
|
||||||
from api.vucem.serializers import VucemSerializer
|
from api.vucem.serializers import VucemSerializer
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class PedimentoSerializer(serializers.ModelSerializer):
|
class PedimentoSerializer(serializers.ModelSerializer):
|
||||||
documentos_count = serializers.SerializerMethodField()
|
documentos_count = serializers.SerializerMethodField()
|
||||||
@@ -47,55 +50,18 @@ class PartidaSerializer(serializers.ModelSerializer):
|
|||||||
documentos = serializers.SerializerMethodField()
|
documentos = serializers.SerializerMethodField()
|
||||||
|
|
||||||
def get_documentos(self, obj):
|
def get_documentos(self, obj):
|
||||||
"""
|
if not obj:
|
||||||
Busca documentos en la tabla `document` que coincidan EXACTAMENTE con:
|
|
||||||
'documents/vu_PT_{pedimentoApp}_{numero}' al inicio del nombre del archivo.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if not obj or not getattr(obj, 'pedimento', None):
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
if not obj or not getattr(obj, 'numero_partida', None):
|
|
||||||
return []
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
pedimentoApp = str(obj.pedimento.pedimento_app).strip()
|
# Documentos de respuesta de la partida (tipo 1) vía la FK real
|
||||||
numero = str(obj.numero_partida).strip()
|
# document.partida. 'documentos_vu' lo precarga el ViewSet con prefetch;
|
||||||
|
# si no está, se consulta directo (retrieve u otros callers).
|
||||||
# Construir el patrón exacto de búsqueda
|
docs = getattr(obj, 'documentos_vu', None)
|
||||||
patron_exacto = f'documents/vu_PT_{pedimentoApp}_{numero}.xml'
|
if docs is None:
|
||||||
|
docs = list(obj.documents.filter(document_type_id=1).select_related('pedimento', 'fuente'))
|
||||||
# Buscar documentos que empiecen EXACTAMENTE con ese patrón
|
return DocumentSerializer(docs, many=True, context=self.context).data
|
||||||
qs = Document.objects.filter(
|
except Exception as e:
|
||||||
archivo=patron_exacto
|
logger.warning("get_documentos partida %s: %s", getattr(obj, 'id', '?'), e)
|
||||||
)
|
|
||||||
|
|
||||||
# Opción 2: Si puede tener diferentes extensiones
|
|
||||||
# patron_base = f'documents/vu_PT_{pedimentoApp}_{numero}'
|
|
||||||
# qs = Document.objects.filter(
|
|
||||||
# archivo__startswith=patron_base
|
|
||||||
# ).filter(
|
|
||||||
# archivo__in=[
|
|
||||||
# f'{patron_base}.xml',
|
|
||||||
# f'{patron_base}.pdf',
|
|
||||||
# f'{patron_base}.zip'
|
|
||||||
# ]
|
|
||||||
# )
|
|
||||||
|
|
||||||
# Filtro adicional por pedimento si el modelo Document tiene este campo
|
|
||||||
if hasattr(Document, 'pedimento'):
|
|
||||||
qs = qs.filter(pedimento=obj.pedimento)
|
|
||||||
|
|
||||||
# Filtro por organización
|
|
||||||
if hasattr(obj, 'organizacion') and obj.organizacion:
|
|
||||||
qs = qs.filter(organizacion=obj.organizacion)
|
|
||||||
|
|
||||||
serializer = DocumentSerializer(qs, many=True, context=self.context)
|
|
||||||
return serializer.data
|
|
||||||
|
|
||||||
#return []
|
|
||||||
except Exception:
|
|
||||||
# En caso de cualquier error (por ejemplo, importaciones circulares), devolver lista vacía
|
|
||||||
return []
|
return []
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Partida
|
model = Partida
|
||||||
@@ -186,42 +152,18 @@ class EDocumentSerializer(serializers.ModelSerializer):
|
|||||||
documentos = serializers.SerializerMethodField()
|
documentos = serializers.SerializerMethodField()
|
||||||
|
|
||||||
def get_documentos(self, obj):
|
def get_documentos(self, obj):
|
||||||
"""
|
"""Documentos del e-documento (incluye acuse y errores; excluye solo los
|
||||||
Busca documentos en la tabla `document` que coincidan con el
|
REQUEST 21/25) vía la FK real document.edocument. 'documentos_vu' lo
|
||||||
`numero_edocument` dentro del nombre del archivo (`archivo`). Se
|
precarga el ViewSet con prefetch; si no está, se consulta directo."""
|
||||||
filtra por organización para evitar devolver documentos de otras orgs.
|
if not obj:
|
||||||
Devuelve la serialización completa de los documentos encontrados:
|
|
||||||
1. Empiecen con 'vu_EDOCUMENT' en el nombre del archivo
|
|
||||||
2. Terminen con el numero_edocument + .xml
|
|
||||||
3. Pertenezcan a la misma organización
|
|
||||||
"""
|
|
||||||
if not obj or not getattr(obj, 'numero_edocument', None):
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
if not obj or not getattr(obj, 'pedimento', None):
|
|
||||||
return []
|
|
||||||
|
|
||||||
# if not obj or not getattr(obj, 'pedimento_id', None):
|
|
||||||
# return []
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
numero = str(obj.numero_edocument).strip()
|
docs = getattr(obj, 'documentos_vu', None)
|
||||||
# id_pedimento = str(obj.pedimento_id).strip()
|
if docs is None:
|
||||||
|
docs = list(obj.documents.exclude(document_type_id__in=[21, 25]).select_related('pedimento', 'fuente'))
|
||||||
qs = Document.objects.filter(
|
return DocumentSerializer(docs, many=True, context=self.context).data
|
||||||
pedimento=obj.pedimento,
|
except Exception as e:
|
||||||
archivo__icontains=numero,
|
logger.warning("get_documentos edocument %s: %s", getattr(obj, 'id', '?'), e)
|
||||||
)
|
|
||||||
|
|
||||||
# Filtro por organización si aplica
|
|
||||||
if hasattr(obj, 'organizacion') and obj.organizacion:
|
|
||||||
qs = qs.filter(organizacion=obj.organizacion)
|
|
||||||
|
|
||||||
serializer = DocumentSerializer(qs, many=True, context=self.context)
|
|
||||||
return serializer.data
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
# En caso de cualquier error (por ejemplo, importaciones circulares), devolver lista vacía
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -229,6 +171,22 @@ class EDocumentSerializer(serializers.ModelSerializer):
|
|||||||
fields = '__all__'
|
fields = '__all__'
|
||||||
read_only_fields = ('created_at', 'updated_at')
|
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):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
# Si no es superusuario, hacer organizacion read_only
|
# Si no es superusuario, hacer organizacion read_only
|
||||||
@@ -244,39 +202,35 @@ class CoveSerializer(serializers.ModelSerializer):
|
|||||||
fields = '__all__'
|
fields = '__all__'
|
||||||
read_only_fields = ('created_at', 'updated_at')
|
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):
|
def get_documentos(self, obj):
|
||||||
"""
|
"""Documentos del cove (incluye acuse cove y errores; excluye solo los
|
||||||
Busca documentos en la tabla `document` que coincidan con el
|
REQUEST 19/23) vía la FK real document.cove. 'documentos_vu' lo precarga
|
||||||
`numero_cove` dentro del nombre del archivo (`archivo`). Se
|
el ViewSet con prefetch; si no está, se consulta directo."""
|
||||||
filtra por organización para evitar devolver documentos de otras orgs.
|
if not obj:
|
||||||
Devuelve la serialización completa de los documentos encontrados:
|
|
||||||
1. Empiecen con 'vu_COVE' en el nombre del archivo
|
|
||||||
2. Terminen con el numero_cove + .xml
|
|
||||||
3. Pertenezcan a la misma organización
|
|
||||||
"""
|
|
||||||
if not obj or not getattr(obj, 'numero_cove', None):
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
if not obj or not getattr(obj, 'pedimento', None):
|
|
||||||
return []
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
numero = str(obj.numero_cove).strip()
|
docs = getattr(obj, 'documentos_vu', None)
|
||||||
|
if docs is None:
|
||||||
qs = Document.objects.filter(
|
docs = list(obj.documents.exclude(document_type_id__in=[19, 23]).select_related('pedimento', 'fuente'))
|
||||||
pedimento=obj.pedimento,
|
return DocumentSerializer(docs, many=True, context=self.context).data
|
||||||
archivo__icontains=numero,
|
except Exception as e:
|
||||||
)
|
logger.warning("get_documentos cove %s: %s", getattr(obj, 'id', '?'), e)
|
||||||
|
|
||||||
# Filtro por organización si aplica
|
|
||||||
if hasattr(obj, 'organizacion') and obj.organizacion:
|
|
||||||
qs = qs.filter(organizacion=obj.organizacion)
|
|
||||||
|
|
||||||
serializer = DocumentSerializer(qs, many=True, context=self.context)
|
|
||||||
return serializer.data
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
# En caso de cualquier error (por ejemplo, importaciones circulares), devolver lista vacía
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
class ImportadorSerializer(serializers.ModelSerializer):
|
class ImportadorSerializer(serializers.ModelSerializer):
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ def trigger_celery_task_on_create(sender, instance, created, **kwargs):
|
|||||||
logger.info("NO es creación de pedimento, no se crea procesamiento.")
|
logger.info("NO es creación de pedimento, no se crea procesamiento.")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if not instance.consultar_vucem:
|
||||||
|
return
|
||||||
|
|
||||||
def crear_procesamiento():
|
def crear_procesamiento():
|
||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger('api.customs.async_operations')
|
logger = logging.getLogger('api.customs.async_operations')
|
||||||
@@ -87,8 +90,11 @@ def trigger_celery_task_on_cove_create(sender, instance, created, **kwargs):
|
|||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger('api.customs.async_operations')
|
logger = logging.getLogger('api.customs.async_operations')
|
||||||
logger.info(f"Cove creado: {instance.id}, creando procesamiento...")
|
logger.info(f"Cove creado: {instance.id}, creando procesamiento...")
|
||||||
crear_procesamiento_cove.apply_async(args=[str(instance.pedimento.id)])
|
pedimento_id = str(instance.pedimento.id)
|
||||||
crear_procesamiento_acuse_cove.apply_async(args=[str(instance.pedimento.id)])
|
def enqueue_cove_tasks():
|
||||||
|
crear_procesamiento_cove.apply_async(args=[pedimento_id])
|
||||||
|
crear_procesamiento_acuse_cove.apply_async(args=[pedimento_id])
|
||||||
|
transaction.on_commit(enqueue_cove_tasks)
|
||||||
|
|
||||||
@receiver(post_save, sender=EDocument)
|
@receiver(post_save, sender=EDocument)
|
||||||
def trigger_celery_task_on_edocument_create(sender, instance, created, **kwargs):
|
def trigger_celery_task_on_edocument_create(sender, instance, created, **kwargs):
|
||||||
@@ -96,5 +102,8 @@ def trigger_celery_task_on_edocument_create(sender, instance, created, **kwargs)
|
|||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger('api.customs.async_operations')
|
logger = logging.getLogger('api.customs.async_operations')
|
||||||
logger.info(f"EDocument creado: {instance.id}, creando procesamiento...")
|
logger.info(f"EDocument creado: {instance.id}, creando procesamiento...")
|
||||||
crear_procesamiento_edocument.apply_async(args=[str(instance.pedimento.id)])
|
pedimento_id = str(instance.pedimento.id)
|
||||||
crear_procesamiento_acuse.apply_async(args=[str(instance.pedimento.id)])
|
def enqueue_edocument_tasks():
|
||||||
|
crear_procesamiento_edocument.apply_async(args=[pedimento_id])
|
||||||
|
crear_procesamiento_acuse.apply_async(args=[pedimento_id])
|
||||||
|
transaction.on_commit(enqueue_edocument_tasks)
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
from .microservice import *
|
from .microservice import *
|
||||||
from .internal_services import *
|
from .internal_services import *
|
||||||
|
from .bulk_upload import *
|
||||||
|
from .microservice_v2 import *
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,8 @@
|
|||||||
# auditoria_xml.py
|
# auditoria_xml.py
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger('api.customs.auditoria_xml')
|
||||||
|
|
||||||
def extraer_info_pedimento_xml(xml_content):
|
def extraer_info_pedimento_xml(xml_content):
|
||||||
"""
|
"""
|
||||||
@@ -13,8 +15,10 @@ def extraer_info_pedimento_xml(xml_content):
|
|||||||
# Buscar el namespace (puede variar)
|
# Buscar el namespace (puede variar)
|
||||||
namespaces = {
|
namespaces = {
|
||||||
'S': 'http://schemas.xmlsoap.org/soap/envelope/',
|
'S': 'http://schemas.xmlsoap.org/soap/envelope/',
|
||||||
|
's': 'http://schemas.xmlsoap.org/soap/envelope/',
|
||||||
'ns2': 'http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/consultarpedimentocompleto',
|
'ns2': 'http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/consultarpedimentocompleto',
|
||||||
'ns3': 'http://www.ventanillaunica.gob.mx/common/ws/oxml/respuesta'
|
'ns3': 'http://www.ventanillaunica.gob.mx/common/ws/oxml/respuesta',
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resultado = {}
|
resultado = {}
|
||||||
@@ -181,10 +185,37 @@ def extraer_info_pedimento_xml(xml_content):
|
|||||||
if edocs_encontrados:
|
if edocs_encontrados:
|
||||||
resultado['edocuments_en_xml'] = edocs_encontrados
|
resultado['edocuments_en_xml'] = edocs_encontrados
|
||||||
|
|
||||||
# Verificar si hay error en la respuesta
|
# Verificar si hay error en la respuesta — 3 variantes según el servicio VUCEM:
|
||||||
|
# 1) Remesas/pedimentos: <ns3:tieneError> en namespace oxml/respuesta
|
||||||
|
# 2) eDocuments: <TieneError> en namespace tempuri.org, mensaje en <Errores>
|
||||||
|
# 3) Acuses: <error> sin namespace dentro de responseConsultaAcuses
|
||||||
tiene_error = root.find('.//ns3:tieneError', namespaces)
|
tiene_error = root.find('.//ns3:tieneError', namespaces)
|
||||||
if tiene_error is not None:
|
if tiene_error is not None:
|
||||||
resultado['tiene_error'] = tiene_error.text.lower() == 'true'
|
resultado['tiene_error'] = tiene_error.text.lower() == 'true'
|
||||||
|
if resultado['tiene_error']:
|
||||||
|
mensaje = root.find('.//ns3:error/ns3:mensaje', namespaces)
|
||||||
|
if mensaje is not None and mensaje.text:
|
||||||
|
resultado['error_mensaje'] = mensaje.text.strip()
|
||||||
|
else:
|
||||||
|
# Variante eDocuments (tempuri.org)
|
||||||
|
tiene_error_edoc = root.find('.//{http://tempuri.org/}TieneError')
|
||||||
|
if tiene_error_edoc is not None:
|
||||||
|
resultado['tiene_error'] = tiene_error_edoc.text.lower() == 'true'
|
||||||
|
if resultado['tiene_error']:
|
||||||
|
errores_elem = root.find('.//{http://tempuri.org/}Errores')
|
||||||
|
if errores_elem is not None and errores_elem.text:
|
||||||
|
resultado['error_mensaje'] = errores_elem.text.strip()
|
||||||
|
else:
|
||||||
|
# Variante acuses: <error> sin namespace
|
||||||
|
error_acuses = root.find('.//error')
|
||||||
|
if error_acuses is not None and error_acuses.text is not None:
|
||||||
|
resultado['tiene_error'] = error_acuses.text.lower() == 'true'
|
||||||
|
if resultado['tiene_error']:
|
||||||
|
descripciones = root.findall('.//mensajeErrores/descripcion')
|
||||||
|
if descripciones:
|
||||||
|
resultado['error_mensaje'] = ' | '.join(
|
||||||
|
d.text.strip() for d in descripciones if d.text
|
||||||
|
)
|
||||||
|
|
||||||
return resultado
|
return resultado
|
||||||
|
|
||||||
|
|||||||
477
api/customs/tasks/auto_corregir.py
Normal file
477
api/customs/tasks/auto_corregir.py
Normal file
@@ -0,0 +1,477 @@
|
|||||||
|
"""
|
||||||
|
Tarea Celery: auto-corrección de pedimentos incompletos a partir de sus XMLs.
|
||||||
|
|
||||||
|
Busca pedimentos con consultar_vucem=False, analiza su documento XML más reciente
|
||||||
|
en busca de una respuesta consultarPedimentoCompleto de VUCEM, y si el número de
|
||||||
|
pedimento coincide, auto-corrige los campos faltantes en BD y reclasifica el documento.
|
||||||
|
|
||||||
|
Campos corregidos (solo si están vacíos/nulos en BD):
|
||||||
|
numero_operacion, aduana, clave_pedimento, regimen, contribuyente (por RFC).
|
||||||
|
|
||||||
|
Acciones sobre el documento si el tipo no es 2 (Pedimento Completo):
|
||||||
|
- Renombra el archivo en MinIO: vu_PC_{pedimento_app}.xml
|
||||||
|
- Actualiza document_type_id → 2
|
||||||
|
- Actualiza vu → False (tipo 2 no es VUCEM directo)
|
||||||
|
|
||||||
|
Al finalizar activa consultar_vucem=True en el pedimento.
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import posixpath
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
from celery import shared_task
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from api.customs.models import Importador, Pedimento, Regimen
|
||||||
|
from api.record.models import Document
|
||||||
|
from api.utils.minio_client import minio_client
|
||||||
|
from core.redis_events import publish_task_event
|
||||||
|
|
||||||
|
logger = logging.getLogger('api.customs.tasks.auto_corregir')
|
||||||
|
|
||||||
|
_DOC_TYPE_PC = 2 # Pedimento Completo (ya procesado — no volver a procesar)
|
||||||
|
_PROGRESS_INTERVAL = 10 # Emitir progreso cada N pedimentos
|
||||||
|
|
||||||
|
# Tipos excluidos de la búsqueda:
|
||||||
|
# 1 = Pedimento Partida (no contiene respuesta PC)
|
||||||
|
# 2 = Pedimento Completo (ya procesado)
|
||||||
|
# 13–26 = Tipos VUCEM: requests, errors de VU (peticiones salientes, no respuestas de contenido)
|
||||||
|
_EXCLUDE_DOC_TYPES = frozenset(range(13, 27)) | {1, _DOC_TYPE_PC}
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# Helpers XML (namespace-agnostic)
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _local(tag):
|
||||||
|
return tag.split('}')[-1] if '}' in tag else tag
|
||||||
|
|
||||||
|
|
||||||
|
def _find_text(root, local_name):
|
||||||
|
"""Primer elemento con ese nombre local; retorna su texto o None."""
|
||||||
|
for el in root.iter():
|
||||||
|
if _local(el.tag) == local_name:
|
||||||
|
text = (el.text or '').strip()
|
||||||
|
return text or None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _find_child_text(root, parent_name, child_name):
|
||||||
|
"""Texto del hijo directo child_name dentro del primer parent_name encontrado."""
|
||||||
|
for el in root.iter():
|
||||||
|
if _local(el.tag) == parent_name:
|
||||||
|
for child in el:
|
||||||
|
if _local(child.tag) == child_name:
|
||||||
|
text = (child.text or '').strip()
|
||||||
|
return text or None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _find_pedimento_number(root):
|
||||||
|
"""
|
||||||
|
Extrae el número de pedimento de la estructura anidada:
|
||||||
|
<ns2:pedimento> ← contenedor
|
||||||
|
<ns2:pedimento>XXXX</ns2:pedimento> ← número
|
||||||
|
"""
|
||||||
|
for el in root.iter():
|
||||||
|
if _local(el.tag) == 'pedimento':
|
||||||
|
for child in el:
|
||||||
|
if _local(child.tag) == 'pedimento':
|
||||||
|
text = (child.text or '').strip()
|
||||||
|
return text or None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# Helpers MinIO
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _read_from_minio(object_name):
|
||||||
|
if not minio_client.file_exists(object_name):
|
||||||
|
return None
|
||||||
|
response = minio_client._client.get_object(minio_client._bucket_name, object_name)
|
||||||
|
try:
|
||||||
|
return response.read()
|
||||||
|
finally:
|
||||||
|
response.close()
|
||||||
|
response.release_conn()
|
||||||
|
|
||||||
|
|
||||||
|
def _rename_in_minio(old_name, new_name, content):
|
||||||
|
if old_name == new_name:
|
||||||
|
return old_name
|
||||||
|
# Si ya existe en destino (ejecución previa parcial): limpiar origen
|
||||||
|
if minio_client.file_exists(new_name):
|
||||||
|
if minio_client.file_exists(old_name):
|
||||||
|
minio_client.delete_file(old_name)
|
||||||
|
return new_name
|
||||||
|
minio_client.upload_file(new_name, file_data=io.BytesIO(content), content_type='application/xml')
|
||||||
|
minio_client.delete_file(old_name)
|
||||||
|
return new_name
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_regimen(clave_pedimento, tipo_operacion_raw):
|
||||||
|
"""
|
||||||
|
Convierte clave_documento + tipo_operacion del XML al código de régimen,
|
||||||
|
replicando la lógica de carga de datastage:
|
||||||
|
Regimen.objects.filter(claveped=clave_pedimento, tipo=tipo_int).regimenped
|
||||||
|
"""
|
||||||
|
if not clave_pedimento or not tipo_operacion_raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
tipo_int = int(tipo_operacion_raw)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
regimen_obj = Regimen.objects.filter(claveped=clave_pedimento, tipo=tipo_int).first()
|
||||||
|
return regimen_obj.regimenped if regimen_obj else None
|
||||||
|
|
||||||
|
|
||||||
|
def _find_pc_document(pedimento):
|
||||||
|
"""
|
||||||
|
Busca entre los XMLs del pedimento el primero que contenga una respuesta
|
||||||
|
consultarPedimentoCompleto de VUCEM.
|
||||||
|
|
||||||
|
Tipos incluidos: 3–12 (documentos de contenido: pedimento, remesas, acuse,
|
||||||
|
edocument, estado, cove, digitalizacion, error, general).
|
||||||
|
Tipos excluidos: 1 (partida), 2 (ya procesado), 13–26 (peticiones/errores VU).
|
||||||
|
|
||||||
|
Retorna (doc, content_bytes, object_name, hay_candidatos):
|
||||||
|
- hay_candidatos=False → ningún XML candidato en BD
|
||||||
|
- hay_candidatos=True, doc=None → hay XMLs pero ninguno es respuesta PC
|
||||||
|
- doc!=None → encontrado
|
||||||
|
"""
|
||||||
|
qs = (
|
||||||
|
Document.objects.filter(
|
||||||
|
pedimento=pedimento,
|
||||||
|
archivo__iendswith='.xml',
|
||||||
|
)
|
||||||
|
.exclude(document_type_id__in=_EXCLUDE_DOC_TYPES)
|
||||||
|
.order_by('-created_at')
|
||||||
|
)
|
||||||
|
|
||||||
|
hay_candidatos = False
|
||||||
|
for doc in qs:
|
||||||
|
if not doc.archivo:
|
||||||
|
continue
|
||||||
|
hay_candidatos = True
|
||||||
|
object_name = doc.archivo.name
|
||||||
|
try:
|
||||||
|
content = _read_from_minio(object_name)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(f"[find_pc] {pedimento.pedimento_app} — error MinIO {object_name}: {exc}")
|
||||||
|
continue
|
||||||
|
if not content:
|
||||||
|
continue
|
||||||
|
if b'consultarPedimentoCompletoRespuesta' in content:
|
||||||
|
return doc, content, object_name, True
|
||||||
|
|
||||||
|
return None, None, None, hay_candidatos
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# Tarea principal
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@shared_task(bind=True, name='auto_corregir_pedamentos')
|
||||||
|
def auto_corregir_pedamentos_task(self, organizacion_id, pedimento_id=None):
|
||||||
|
"""
|
||||||
|
Itera pedimentos con consultar_vucem=False de la organización.
|
||||||
|
Si se proporciona pedimento_id, procesa solo ese pedimento.
|
||||||
|
Por cada uno verifica si tiene un XML de pedimento completo válido
|
||||||
|
y corrige BD + storage.
|
||||||
|
"""
|
||||||
|
task_id = self.request.id
|
||||||
|
revisados = 0
|
||||||
|
corregidos = 0
|
||||||
|
ignorados = 0
|
||||||
|
detalles = []
|
||||||
|
|
||||||
|
qs = Pedimento.objects.filter(consultar_vucem=False).order_by('pedimento_app')
|
||||||
|
if pedimento_id:
|
||||||
|
qs = qs.filter(id=pedimento_id)
|
||||||
|
else:
|
||||||
|
qs = qs.filter(organizacion_id=organizacion_id)
|
||||||
|
|
||||||
|
total = qs.count()
|
||||||
|
logger.info(f"[auto_corregir] org={organizacion_id} — {total} pedimentos a revisar")
|
||||||
|
|
||||||
|
publish_task_event(task_id, 'processing', f'Iniciando: {total} pedimentos a revisar', progress=0)
|
||||||
|
|
||||||
|
for idx, pedimento in enumerate(qs.iterator(chunk_size=100)):
|
||||||
|
revisados += 1
|
||||||
|
|
||||||
|
if total > 0 and (idx % _PROGRESS_INTERVAL == 0 or idx == total - 1):
|
||||||
|
pct = int(((idx + 1) / total) * 95)
|
||||||
|
publish_task_event(
|
||||||
|
task_id, 'processing',
|
||||||
|
f'Revisando {idx + 1}/{total}: {pedimento.pedimento_app}',
|
||||||
|
progress=pct,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Buscar XML con respuesta de pedimento completo (evalúa todos, VUCEM primero)
|
||||||
|
try:
|
||||||
|
candidato, content, object_name, hay_candidatos = _find_pc_document(pedimento)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"[auto_corregir] {pedimento.pedimento_app} — error buscando PC: {exc}")
|
||||||
|
ignorados += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not candidato:
|
||||||
|
ignorados += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
root = ET.fromstring(content)
|
||||||
|
except ET.ParseError as exc:
|
||||||
|
logger.warning(f"[auto_corregir] {pedimento.pedimento_app} — XML inválido: {exc}")
|
||||||
|
ignorados += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
tiene_error = _find_text(root, 'tieneError')
|
||||||
|
if tiene_error and tiene_error.lower() == 'true':
|
||||||
|
ignorados += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
pedimento_xml = _find_pedimento_number(root)
|
||||||
|
pedimento_bd = (pedimento.pedimento or '').strip()
|
||||||
|
if not pedimento_xml or pedimento_xml != pedimento_bd:
|
||||||
|
logger.info(
|
||||||
|
f"[auto_corregir] {pedimento.pedimento_app} — número no coincide "
|
||||||
|
f"(XML={pedimento_xml!r}, BD={pedimento_bd!r})"
|
||||||
|
)
|
||||||
|
ignorados += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ── Extracción de campos ──────────────────
|
||||||
|
numero_operacion = _find_text(root, 'numeroOperacion')
|
||||||
|
aduana = _find_child_text(root, 'aduanaEntradaSalida', 'clave')
|
||||||
|
clave_pedimento = _find_child_text(root, 'claveDocumento', 'clave')
|
||||||
|
tipo_operacion_raw = _find_child_text(root, 'tipoOperacion', 'clave')
|
||||||
|
regimen = _resolve_regimen(clave_pedimento, tipo_operacion_raw)
|
||||||
|
rfc = _find_child_text(root, 'importadorExportador', 'rfc')
|
||||||
|
|
||||||
|
ped_fields = []
|
||||||
|
if numero_operacion and not pedimento.numero_operacion:
|
||||||
|
pedimento.numero_operacion = numero_operacion
|
||||||
|
ped_fields.append('numero_operacion')
|
||||||
|
if aduana and aduana != (pedimento.aduana or '').strip():
|
||||||
|
pedimento.aduana = aduana
|
||||||
|
ped_fields.append('aduana')
|
||||||
|
if clave_pedimento and clave_pedimento != (pedimento.clave_pedimento or '').strip():
|
||||||
|
pedimento.clave_pedimento = clave_pedimento
|
||||||
|
ped_fields.append('clave_pedimento')
|
||||||
|
if regimen and not pedimento.regimen:
|
||||||
|
pedimento.regimen = regimen
|
||||||
|
ped_fields.append('regimen')
|
||||||
|
|
||||||
|
if rfc:
|
||||||
|
try:
|
||||||
|
importador = Importador.objects.get(rfc=rfc)
|
||||||
|
if pedimento.contribuyente_id != importador.rfc:
|
||||||
|
pedimento.contribuyente_id = importador.rfc
|
||||||
|
ped_fields.append('contribuyente')
|
||||||
|
except Importador.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
pedimento.consultar_vucem = True
|
||||||
|
ped_fields.append('consultar_vucem')
|
||||||
|
|
||||||
|
# ── Renombrado de documento si no es tipo 2 ──
|
||||||
|
doc_fields = ['document_type_id', 'vu']
|
||||||
|
final_object_name = object_name
|
||||||
|
|
||||||
|
if candidato.document_type_id != _DOC_TYPE_PC:
|
||||||
|
dir_part = posixpath.dirname(object_name)
|
||||||
|
new_filename = f"vu_PC_{pedimento.pedimento_app}.xml"
|
||||||
|
new_object_name = posixpath.join(dir_part, new_filename)
|
||||||
|
try:
|
||||||
|
final_object_name = _rename_in_minio(object_name, new_object_name, content)
|
||||||
|
doc_fields.append('archivo')
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"[auto_corregir] {pedimento.pedimento_app} — error renombrando en MinIO: {exc}")
|
||||||
|
|
||||||
|
# ── Persistir cambios en BD ───────────────
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
pedimento.save(update_fields=ped_fields)
|
||||||
|
candidato.document_type_id = _DOC_TYPE_PC
|
||||||
|
candidato.vu = False
|
||||||
|
if 'archivo' in doc_fields:
|
||||||
|
candidato.archivo = final_object_name
|
||||||
|
candidato.save(update_fields=doc_fields)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"[auto_corregir] {pedimento.pedimento_app} — error guardando en BD: {exc}")
|
||||||
|
ignorados += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
corregidos += 1
|
||||||
|
detalles.append({
|
||||||
|
'pedimento': pedimento.pedimento_app,
|
||||||
|
'accion': 'corregido',
|
||||||
|
'campos_pedimento': ped_fields,
|
||||||
|
'documento_final': final_object_name,
|
||||||
|
})
|
||||||
|
logger.info(f"[auto_corregir] {pedimento.pedimento_app} — corregido: {ped_fields}")
|
||||||
|
|
||||||
|
# Modo individual: encolar el procesamiento completo (remesas, partidas,
|
||||||
|
# coves, edocs) forzando aunque ya exista el documento tipo 2.
|
||||||
|
if pedimento_id:
|
||||||
|
try:
|
||||||
|
from .microservice_v2 import procesar_pedimento_completo_individual
|
||||||
|
procesar_pedimento_completo_individual.delay(str(pedimento.id), force=True)
|
||||||
|
logger.info(f"[auto_corregir] {pedimento.pedimento_app} — PC completo encolado (force)")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"[auto_corregir] {pedimento.pedimento_app} — no se pudo encolar PC: {exc}")
|
||||||
|
|
||||||
|
resultado = {
|
||||||
|
'total_revisados': revisados,
|
||||||
|
'corregidos': corregidos,
|
||||||
|
'ignorados': ignorados,
|
||||||
|
'detalles': detalles,
|
||||||
|
}
|
||||||
|
logger.info(f"[auto_corregir] org={organizacion_id} finalizado — {resultado}")
|
||||||
|
|
||||||
|
publish_task_event(task_id, 'completed', 'Auto-corrección finalizada', resultado=resultado, progress=100)
|
||||||
|
return resultado
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# Tarea de análisis (sin modificar nada)
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _campos_a_corregir(pedimento, numero_operacion, aduana, clave_pedimento, regimen, rfc):
|
||||||
|
"""Retorna la lista de campos que se corregirían y los valores que se asignarían."""
|
||||||
|
campos = []
|
||||||
|
if numero_operacion and not pedimento.numero_operacion:
|
||||||
|
campos.append({'campo': 'numero_operacion', 'valor_actual': None, 'valor_nuevo': numero_operacion})
|
||||||
|
if aduana and aduana != (pedimento.aduana or '').strip():
|
||||||
|
campos.append({'campo': 'aduana', 'valor_actual': pedimento.aduana, 'valor_nuevo': aduana})
|
||||||
|
if clave_pedimento and clave_pedimento != (pedimento.clave_pedimento or '').strip():
|
||||||
|
campos.append({'campo': 'clave_pedimento', 'valor_actual': pedimento.clave_pedimento, 'valor_nuevo': clave_pedimento})
|
||||||
|
if regimen and not pedimento.regimen:
|
||||||
|
campos.append({'campo': 'regimen', 'valor_actual': None, 'valor_nuevo': regimen})
|
||||||
|
if rfc:
|
||||||
|
try:
|
||||||
|
importador = Importador.objects.get(rfc=rfc)
|
||||||
|
if pedimento.contribuyente_id != importador.rfc:
|
||||||
|
campos.append({
|
||||||
|
'campo': 'contribuyente',
|
||||||
|
'valor_actual': pedimento.contribuyente_id,
|
||||||
|
'valor_nuevo': rfc,
|
||||||
|
})
|
||||||
|
except Importador.DoesNotExist:
|
||||||
|
pass
|
||||||
|
return campos
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True, name='auditar_pedamentos_incompletos')
|
||||||
|
def auditar_pedamentos_incompletos_task(self, organizacion_id, pedimento_id=None):
|
||||||
|
"""
|
||||||
|
Análisis de solo lectura: reporta qué pedimentos serían corregidos y qué
|
||||||
|
cambios se aplicarían, sin modificar BD ni storage.
|
||||||
|
Si se proporciona pedimento_id, analiza solo ese pedimento.
|
||||||
|
"""
|
||||||
|
task_id = self.request.id
|
||||||
|
revisados = 0
|
||||||
|
corregibles = []
|
||||||
|
sin_xml = 0
|
||||||
|
xml_sin_pc = 0
|
||||||
|
num_no_coincide = 0
|
||||||
|
con_error_vucem = 0
|
||||||
|
|
||||||
|
# Individual: analiza el pedimento específico sin importar su estado de corrección.
|
||||||
|
# Masivo: solo los pendientes (consultar_vucem=False).
|
||||||
|
if pedimento_id:
|
||||||
|
qs = Pedimento.objects.filter(id=pedimento_id).order_by('pedimento_app')
|
||||||
|
else:
|
||||||
|
qs = Pedimento.objects.filter(
|
||||||
|
organizacion_id=organizacion_id, consultar_vucem=False
|
||||||
|
).order_by('pedimento_app')
|
||||||
|
|
||||||
|
total = qs.count()
|
||||||
|
logger.info(f"[auditar_incompletos] org={organizacion_id} — {total} pedimentos a analizar")
|
||||||
|
|
||||||
|
publish_task_event(task_id, 'processing', f'Iniciando análisis: {total} pedimentos', progress=0)
|
||||||
|
|
||||||
|
for idx, pedimento in enumerate(qs.iterator(chunk_size=100)):
|
||||||
|
revisados += 1
|
||||||
|
|
||||||
|
if total > 0 and (idx % _PROGRESS_INTERVAL == 0 or idx == total - 1):
|
||||||
|
pct = int(((idx + 1) / total) * 95)
|
||||||
|
publish_task_event(
|
||||||
|
task_id, 'processing',
|
||||||
|
f'Analizando {idx + 1}/{total}: {pedimento.pedimento_app}',
|
||||||
|
progress=pct,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Buscar XML con respuesta de pedimento completo (evalúa todos, VUCEM primero)
|
||||||
|
try:
|
||||||
|
candidato, content, object_name, hay_candidatos = _find_pc_document(pedimento)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"[auditar_incompletos] {pedimento.pedimento_app} — error buscando PC: {exc}")
|
||||||
|
sin_xml += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not candidato:
|
||||||
|
if hay_candidatos:
|
||||||
|
xml_sin_pc += 1
|
||||||
|
else:
|
||||||
|
sin_xml += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
root = ET.fromstring(content)
|
||||||
|
except ET.ParseError:
|
||||||
|
xml_sin_pc += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
tiene_error = _find_text(root, 'tieneError')
|
||||||
|
if tiene_error and tiene_error.lower() == 'true':
|
||||||
|
con_error_vucem += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
pedimento_xml = _find_pedimento_number(root)
|
||||||
|
pedimento_bd = (pedimento.pedimento or '').strip()
|
||||||
|
if not pedimento_xml or pedimento_xml != pedimento_bd:
|
||||||
|
num_no_coincide += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
numero_operacion = _find_text(root, 'numeroOperacion')
|
||||||
|
aduana = _find_child_text(root, 'aduanaEntradaSalida', 'clave')
|
||||||
|
clave_pedimento = _find_child_text(root, 'claveDocumento', 'clave')
|
||||||
|
tipo_operacion_raw = _find_child_text(root, 'tipoOperacion', 'clave')
|
||||||
|
regimen = _resolve_regimen(clave_pedimento, tipo_operacion_raw)
|
||||||
|
rfc = _find_child_text(root, 'importadorExportador', 'rfc')
|
||||||
|
|
||||||
|
campos = _campos_a_corregir(pedimento, numero_operacion, aduana, clave_pedimento, regimen, rfc)
|
||||||
|
|
||||||
|
dir_part = posixpath.dirname(object_name)
|
||||||
|
nombre_pc = posixpath.join(dir_part, f"vu_PC_{pedimento.pedimento_app}.xml")
|
||||||
|
|
||||||
|
corregibles.append({
|
||||||
|
'pedimento_app': pedimento.pedimento_app,
|
||||||
|
'pedimento_id': str(pedimento.id),
|
||||||
|
'documento_actual': {
|
||||||
|
'id': str(candidato.id),
|
||||||
|
'archivo': object_name,
|
||||||
|
'document_type_id': candidato.document_type_id,
|
||||||
|
},
|
||||||
|
'documento_nuevo_nombre': nombre_pc if candidato.document_type_id != _DOC_TYPE_PC else None,
|
||||||
|
'campos_a_corregir': campos,
|
||||||
|
'consultar_vucem': True,
|
||||||
|
})
|
||||||
|
|
||||||
|
resultado = {
|
||||||
|
'total_revisados': revisados,
|
||||||
|
'corregibles': len(corregibles),
|
||||||
|
'sin_xml_o_ilegible': sin_xml,
|
||||||
|
'xml_no_es_pedimento_completo': xml_sin_pc,
|
||||||
|
'numero_pedimento_no_coincide': num_no_coincide,
|
||||||
|
'con_error_vucem': con_error_vucem,
|
||||||
|
'pedimentos': corregibles,
|
||||||
|
}
|
||||||
|
logger.info(f"[auditar_incompletos] org={organizacion_id} finalizado — {resultado}")
|
||||||
|
|
||||||
|
publish_task_event(task_id, 'completed', 'Análisis finalizado', resultado=resultado, progress=100)
|
||||||
|
return resultado
|
||||||
710
api/customs/tasks/bulk_upload.py
Normal file
710
api/customs/tasks/bulk_upload.py
Normal file
@@ -0,0 +1,710 @@
|
|||||||
|
from celery import shared_task
|
||||||
|
from django.utils import timezone
|
||||||
|
import os
|
||||||
|
import zipfile
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_filename(filename):
|
||||||
|
"""
|
||||||
|
Normaliza el nombre del archivo removiendo caracteres especiales,
|
||||||
|
espacios y asegurando consistencia.
|
||||||
|
"""
|
||||||
|
from unicodedata import normalize
|
||||||
|
filename = normalize('NFKD', filename).encode('ASCII', 'ignore').decode('ASCII')
|
||||||
|
filename = re.sub(r'[^\w\s.-]', '_', filename)
|
||||||
|
filename = re.sub(r'[\s()]+', '_', filename)
|
||||||
|
filename = re.sub(r'_+', '_', filename)
|
||||||
|
filename = filename.strip('_')
|
||||||
|
return filename
|
||||||
|
|
||||||
|
|
||||||
|
def extract_django_suffix(filename):
|
||||||
|
"""
|
||||||
|
Extrae el sufijo UUID de 8 chars que storage_service añade a los archivos.
|
||||||
|
"""
|
||||||
|
name_without_ext = os.path.splitext(filename)[0]
|
||||||
|
match = re.search(r'_([a-zA-Z0-9]{8})$', name_without_ext)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_clean_base_filename(filename):
|
||||||
|
"""
|
||||||
|
Obtiene el nombre base limpio sin el sufijo UUID de storage_service.
|
||||||
|
"""
|
||||||
|
normalized = normalize_filename(filename)
|
||||||
|
name_without_ext, ext = os.path.splitext(normalized)
|
||||||
|
|
||||||
|
django_suffix = extract_django_suffix(name_without_ext)
|
||||||
|
if django_suffix:
|
||||||
|
base_name = name_without_ext[:-9] # elimina _XXXXXXXX (underscore + 8 chars UUID)
|
||||||
|
else:
|
||||||
|
base_name = name_without_ext
|
||||||
|
|
||||||
|
base_name = re.sub(r'(_copy|_copia|_-_copia|_-_copy)(_\d+)?$', '', base_name)
|
||||||
|
|
||||||
|
return base_name.lower().strip('_')
|
||||||
|
|
||||||
|
|
||||||
|
def is_same_document(existing_doc, new_filename):
|
||||||
|
"""
|
||||||
|
Compara si un documento existente y un nuevo archivo son el mismo documento.
|
||||||
|
"""
|
||||||
|
existing_basename = os.path.basename(existing_doc.archivo.name)
|
||||||
|
existing_base = get_clean_base_filename(existing_basename)
|
||||||
|
|
||||||
|
new_base = get_clean_base_filename(new_filename)
|
||||||
|
|
||||||
|
existing_ext = existing_doc.extension.lower()
|
||||||
|
new_ext = os.path.splitext(new_filename)[1].lower().lstrip('.')
|
||||||
|
|
||||||
|
return existing_base == new_base and existing_ext == new_ext
|
||||||
|
|
||||||
|
|
||||||
|
def procesar_archivo_m_con_nomenclatura(content, pedimento_instance):
|
||||||
|
"""
|
||||||
|
Procesa archivos con nomenclatura M8988852.300 (7 dígitos, punto, 3 dígitos)
|
||||||
|
y extrae información de registros específicos para actualizar el pedimento.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: bytes del contenido del archivo
|
||||||
|
pedimento_instance: instancia del modelo Pedimento
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Diccionario con información extraída
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
content_text = content.decode('utf-8', errors='ignore')
|
||||||
|
|
||||||
|
registros = {}
|
||||||
|
|
||||||
|
for line in content_text.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
parts = line.split('|')
|
||||||
|
if len(parts) < 2:
|
||||||
|
continue
|
||||||
|
|
||||||
|
tipo_registro = parts[0]
|
||||||
|
|
||||||
|
if tipo_registro not in registros:
|
||||||
|
registros[tipo_registro] = []
|
||||||
|
registros[tipo_registro].append(parts)
|
||||||
|
|
||||||
|
info_extraida = {
|
||||||
|
'tiene_nomenclatura_especial': False,
|
||||||
|
'registros_encontrados': list(registros.keys()),
|
||||||
|
'detalles_registro_500': [],
|
||||||
|
'detalles_registro_506': [],
|
||||||
|
'detalles_registro_501': [],
|
||||||
|
'detalles_registro_551': [],
|
||||||
|
'detalles_registro_800': [],
|
||||||
|
'detalles_registro_801': [],
|
||||||
|
'actualizaciones_aplicadas': []
|
||||||
|
}
|
||||||
|
|
||||||
|
if '500' in registros:
|
||||||
|
info_extraida['tiene_nomenclatura_especial'] = True
|
||||||
|
|
||||||
|
for reg_500 in registros['500']:
|
||||||
|
if len(reg_500) >= 1:
|
||||||
|
info_extraida['detalles_registro_500'].append({
|
||||||
|
'tipo_movimiento': reg_500[1] if len(reg_500) > 1 else None,
|
||||||
|
'patente': reg_500[2] if len(reg_500) > 1 else None,
|
||||||
|
'numero_pedimento': reg_500[3] if len(reg_500) > 1 else None,
|
||||||
|
'aduana_seccion': reg_500[4] if len(reg_500) > 1 else None,
|
||||||
|
'acuse_electronico': reg_500[5] if len(reg_500) > 1 else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
for reg_506 in registros.get('506', []):
|
||||||
|
if len(reg_506) >= 1:
|
||||||
|
info_extraida['detalles_registro_506'].append({
|
||||||
|
'numero_pedimento': reg_506[1] if len(reg_506) > 1 else None,
|
||||||
|
'tipo_fecha': reg_506[2] if len(reg_506) > 1 else None,
|
||||||
|
'fecha': reg_506[3] if len(reg_506) > 1 else None
|
||||||
|
})
|
||||||
|
|
||||||
|
for reg_501 in registros.get('501', []):
|
||||||
|
if len(reg_501) >= 1:
|
||||||
|
info_extraida['detalles_registro_501'].append({
|
||||||
|
'patente': reg_501[1] if len(reg_501) > 1 else None,
|
||||||
|
'numero_pedimento': reg_501[2] if len(reg_501) > 1 else None,
|
||||||
|
'aduana_seccion': reg_501[3] if len(reg_501) > 1 else None,
|
||||||
|
'rfc': reg_501[8] if len(reg_501) > 1 else None,
|
||||||
|
'curp': reg_501[9] if len(reg_501) > 1 else None
|
||||||
|
})
|
||||||
|
|
||||||
|
for reg_551 in registros.get('551', []):
|
||||||
|
if len(reg_551) >= 1:
|
||||||
|
info_extraida['detalles_registro_551'].append({
|
||||||
|
'numero_pedimento': reg_501[1] if len(reg_501) > 1 else None,
|
||||||
|
'fraccion_arancelaria': reg_551[2] if len(reg_551) > 1 else None,
|
||||||
|
'partida': reg_551[3] if len(reg_551) > 1 else None,
|
||||||
|
'subfraccion': reg_551[4] if len(reg_551) > 1 else None
|
||||||
|
})
|
||||||
|
|
||||||
|
for reg_801 in registros.get('800', []):
|
||||||
|
if len(reg_801) >= 1:
|
||||||
|
info_extraida['detalles_registro_800'].append({
|
||||||
|
'numero_pedimento': reg_801[1] if len(reg_801) > 1 else None
|
||||||
|
})
|
||||||
|
|
||||||
|
for reg_801 in registros.get('801', []):
|
||||||
|
if len(reg_801) >= 1:
|
||||||
|
info_extraida['detalles_registro_801'].append({
|
||||||
|
'total_partidas': reg_801[1] if len(reg_801) > 1 else None
|
||||||
|
})
|
||||||
|
|
||||||
|
actualizaciones = actualizar_pedimento_con_registros(pedimento_instance, registros)
|
||||||
|
info_extraida['actualizaciones_aplicadas'] = actualizaciones
|
||||||
|
|
||||||
|
return info_extraida
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error al procesar archivo con nomenclatura especial: {str(e)}")
|
||||||
|
return {
|
||||||
|
'tiene_nomenclatura_especial': False,
|
||||||
|
'error': str(e),
|
||||||
|
'registros_encontrados': []
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def actualizar_pedimento_con_registros(pedimento_instance, registros):
|
||||||
|
"""
|
||||||
|
Actualiza el pedimento con información extraída de los registros.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pedimento_instance: Instancia del pedimento a actualizar
|
||||||
|
registros: Diccionario con registros parseados
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: Lista de actualizaciones aplicadas
|
||||||
|
"""
|
||||||
|
actualizaciones = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
if '500' in registros and registros['500']:
|
||||||
|
for reg_500 in registros['500']:
|
||||||
|
if len(reg_500) >= 1:
|
||||||
|
if pedimento_instance.pedimento == reg_500[3]:
|
||||||
|
try:
|
||||||
|
pedimento_instance.aduana = reg_500[4]
|
||||||
|
actualizaciones.append(f"aduana actualizada a {reg_500[4]}")
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if '501' in registros and registros['501']:
|
||||||
|
for reg_501 in registros['501']:
|
||||||
|
if len(reg_501) >= 1:
|
||||||
|
rfc = reg_501[8] if len(reg_501) > 1 else None
|
||||||
|
|
||||||
|
if rfc and not pedimento_instance.contribuyente and pedimento_instance.pedimento == reg_501[2]:
|
||||||
|
try:
|
||||||
|
from api.customs.models import Importador
|
||||||
|
importador, created = Importador.objects.get_or_create(
|
||||||
|
rfc=rfc,
|
||||||
|
defaults={
|
||||||
|
'nombre': f"Importador {rfc}",
|
||||||
|
'organizacion': pedimento_instance.organizacion
|
||||||
|
}
|
||||||
|
)
|
||||||
|
pedimento_instance.contribuyente = importador
|
||||||
|
if created:
|
||||||
|
actualizaciones.append(f"importador creado con RFC {rfc}")
|
||||||
|
else:
|
||||||
|
actualizaciones.append(f"importador asociado con RFC {rfc}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error al crear/obtener importador: {str(e)}")
|
||||||
|
|
||||||
|
if '501' in registros and registros['501']:
|
||||||
|
for reg_501 in registros['501']:
|
||||||
|
if len(reg_501) >= 1:
|
||||||
|
curp = reg_501[9] if len(reg_501) > 1 else None
|
||||||
|
if curp and not pedimento_instance.curp_apoderado and pedimento_instance.pedimento == reg_501[2]:
|
||||||
|
pedimento_instance.curp_apoderado = curp
|
||||||
|
actualizaciones.append(f"curp_apoderado actualizado a {curp}")
|
||||||
|
|
||||||
|
if '501' in registros and registros['501']:
|
||||||
|
for reg_501 in registros['501']:
|
||||||
|
if len(reg_501) >= 1:
|
||||||
|
tipo_operacion = reg_501[4] if len(reg_501) > 1 else None
|
||||||
|
if tipo_operacion and pedimento_instance.pedimento == reg_501[2]:
|
||||||
|
|
||||||
|
if tipo_operacion=='1':
|
||||||
|
nombre_tipo_op = "Importacion"
|
||||||
|
elif tipo_operacion=='2':
|
||||||
|
nombre_tipo_op = "Exportacion"
|
||||||
|
else:
|
||||||
|
nombre_tipo_op = f"Tipo {tipo_operacion}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
from api.customs.models import TipoOperacion
|
||||||
|
tipo_op_obj, created = TipoOperacion.objects.get_or_create(
|
||||||
|
id=tipo_operacion,
|
||||||
|
tipo=nombre_tipo_op,
|
||||||
|
defaults={'descripcion': f"Tipo de Operación {tipo_operacion}"}
|
||||||
|
)
|
||||||
|
pedimento_instance.tipo_operacion = tipo_op_obj
|
||||||
|
if created:
|
||||||
|
actualizaciones.append(f"tipo_operacion creado con tipo {tipo_operacion}")
|
||||||
|
else:
|
||||||
|
actualizaciones.append(f"tipo_operacion asociado con tipo {tipo_operacion}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error al crear/obtener tipo de operación: {str(e)}")
|
||||||
|
|
||||||
|
if '501' in registros and registros['501']:
|
||||||
|
for reg_501 in registros['501']:
|
||||||
|
if len(reg_501) >= 1:
|
||||||
|
clave = reg_501[5] if len(reg_501) > 1 else None
|
||||||
|
if clave and pedimento_instance.pedimento == reg_501[2]:
|
||||||
|
pedimento_instance.clave_pedimento = clave
|
||||||
|
actualizaciones.append(f"clave pedimento actualizada a {clave}")
|
||||||
|
|
||||||
|
if '506' in registros and registros['506']:
|
||||||
|
for reg_506 in registros['506']:
|
||||||
|
|
||||||
|
if not pedimento_instance.pedimento == reg_506[1]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if len(reg_506) >= 1:
|
||||||
|
tipo_fecha = reg_506[2] if len(reg_506) > 1 else None
|
||||||
|
fecha_str = reg_506[3] if len(reg_506) > 1 else None
|
||||||
|
|
||||||
|
if not tipo_fecha == '2':
|
||||||
|
continue
|
||||||
|
|
||||||
|
if fecha_str:
|
||||||
|
try:
|
||||||
|
if len(fecha_str) == 8:
|
||||||
|
fecha = datetime.strptime(fecha_str, '%d%m%Y').date()
|
||||||
|
elif len(fecha_str) == 6:
|
||||||
|
fecha = datetime.strptime(fecha_str, '%d%m%y').date()
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
pedimento_instance.fecha_pago = fecha
|
||||||
|
actualizaciones.append(f"fecha_pago actualizada a {fecha}")
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
num_partidas = 0
|
||||||
|
if '551' in registros and registros['551']:
|
||||||
|
for reg_551 in registros['551']:
|
||||||
|
if not pedimento_instance.pedimento == reg_551[1]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
num_partidas += 1
|
||||||
|
pedimento_instance.numero_partidas = num_partidas
|
||||||
|
actualizaciones.append(f"numero_partidas actualizado a {num_partidas}")
|
||||||
|
|
||||||
|
|
||||||
|
if actualizaciones:
|
||||||
|
pedimento_instance.save()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error al actualizar pedimento con registros: {str(e)}")
|
||||||
|
actualizaciones.append(f"error: {str(e)}")
|
||||||
|
|
||||||
|
return actualizaciones
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True, max_retries=3, time_limit=600)
|
||||||
|
def bulk_upload_record_task(self, organizacion_id, parametros, archivo_paths):
|
||||||
|
"""
|
||||||
|
Procesa archivos ZIP de pedimentos en segundo plano.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
organizacion_id: UUID de la organización
|
||||||
|
parametros: dict con keys:
|
||||||
|
- contribuyente
|
||||||
|
- fecha_pago_input
|
||||||
|
- clave_pedimento_input
|
||||||
|
- patente_input
|
||||||
|
- tipo_operacion_input
|
||||||
|
- aduana_input
|
||||||
|
- curp_apoderado_input
|
||||||
|
- partidas_input
|
||||||
|
archivo_paths: lista de rutas temporales de archivos ZIP
|
||||||
|
"""
|
||||||
|
from api.organization.models import Organizacion
|
||||||
|
from api.customs.models import Pedimento, Importador, TipoOperacion
|
||||||
|
from api.record.models import Document, DocumentType, Fuente
|
||||||
|
|
||||||
|
created_pedimentos = []
|
||||||
|
updated_pedimentos = []
|
||||||
|
failed_records = []
|
||||||
|
documents_created = 0
|
||||||
|
temp_dir = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
organizacion = Organizacion.objects.get(id=organizacion_id)
|
||||||
|
|
||||||
|
# Extraer parámetros
|
||||||
|
contribuyente = parametros.get('contribuyente', None)
|
||||||
|
fecha_pago_input = parametros.get('fecha_pago_input', None)
|
||||||
|
clave_pedimento_input = parametros.get('clave_pedimento_input', None)
|
||||||
|
patente_input = parametros.get('patente_input', None)
|
||||||
|
tipo_operacion_input = parametros.get('tipo_operacion_input', None)
|
||||||
|
aduana_input = parametros.get('aduana_input', None)
|
||||||
|
curp_apoderado_input = parametros.get('curp_apoderado_input', None)
|
||||||
|
partidas_input = parametros.get('partidas_input', None)
|
||||||
|
|
||||||
|
# Regex patterns
|
||||||
|
nomenclatura_pattern = re.compile(r'^(\d{2})-(\d{2,3})-(\d{4})-(\d{7})$')
|
||||||
|
nomenclatura_pattern_sin_anio = re.compile(r'^(\d{2,3})-(\d{4})-(\d{7})$')
|
||||||
|
|
||||||
|
# Obtener DocumentType
|
||||||
|
try:
|
||||||
|
document_type = DocumentType.objects.get(nombre="Pedimento")
|
||||||
|
except DocumentType.DoesNotExist:
|
||||||
|
document_type = DocumentType.objects.create(
|
||||||
|
nombre="Pedimento",
|
||||||
|
descripcion="Documento de pedimento"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fuente
|
||||||
|
fuente, _ = Fuente.objects.get_or_create(
|
||||||
|
nombre="APP-EFC",
|
||||||
|
descripcion='Transmitido por la app de escritorio'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Usar el directorio donde están los archivos (ya guardado en MEDIA_ROOT)
|
||||||
|
# El directorio base es el padre del primer archivo
|
||||||
|
if archivo_paths:
|
||||||
|
temp_dir = os.path.dirname(archivo_paths[0])
|
||||||
|
else:
|
||||||
|
temp_dir = tempfile.mkdtemp()
|
||||||
|
|
||||||
|
# Patrón para nomenclatura especial M8988852.300
|
||||||
|
patron_nomenclatura = re.compile(r'^[m|M]\d{7}\.\d{3}$', re.IGNORECASE)
|
||||||
|
|
||||||
|
existing_pedimento = None
|
||||||
|
|
||||||
|
for archivo_path in archivo_paths:
|
||||||
|
archivo_name = os.path.basename(archivo_path).lower()
|
||||||
|
archivo_name_sin_extension = os.path.splitext(os.path.basename(archivo_path))[0]
|
||||||
|
|
||||||
|
sub_dir = os.path.join(temp_dir, archivo_name_sin_extension)
|
||||||
|
os.makedirs(sub_dir, exist_ok=True)
|
||||||
|
|
||||||
|
print(f"Procesando archivo: {archivo_name} en ruta temporal: {archivo_path}")
|
||||||
|
|
||||||
|
if archivo_name.endswith('.zip'):
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(archivo_path, 'r') as zip_ref:
|
||||||
|
zip_ref.extractall(sub_dir)
|
||||||
|
os.remove(archivo_path) # Eliminar el archivo ZIP después de extraerlo
|
||||||
|
except zipfile.BadZipFile as e:
|
||||||
|
failed_records.append({
|
||||||
|
"file": archivo_path,
|
||||||
|
"archivo_original": archivo_name,
|
||||||
|
"error": f"Archivo ZIP corrupto o inválido: {str(e)}"
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
failed_records.append({
|
||||||
|
"file": archivo_path,
|
||||||
|
"archivo_original": archivo_name,
|
||||||
|
"error": f"Error al extraer ZIP: {str(e)}"
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
failed_records.append({
|
||||||
|
"file": archivo_path,
|
||||||
|
"archivo_original": archivo_name,
|
||||||
|
"error": "Solo se admiten archivos ZIP"
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Procesar archivos extraídos
|
||||||
|
for root, dirs, files in os.walk(temp_dir):
|
||||||
|
for file_name in files:
|
||||||
|
file_path = os.path.join(root, file_name)
|
||||||
|
relative_path = os.path.relpath(file_path, temp_dir)
|
||||||
|
|
||||||
|
# Determinar folder_name
|
||||||
|
folder_name = None
|
||||||
|
if os.path.dirname(relative_path):
|
||||||
|
folder_parts = relative_path.split(os.sep)
|
||||||
|
folder_name = folder_parts[0]
|
||||||
|
else:
|
||||||
|
folder_name = os.path.splitext(file_name)[0]
|
||||||
|
|
||||||
|
# Validar nomenclatura
|
||||||
|
match = nomenclatura_pattern.match(folder_name)
|
||||||
|
match_sin_anio = nomenclatura_pattern_sin_anio.match(folder_name)
|
||||||
|
|
||||||
|
if not match and not match_sin_anio:
|
||||||
|
archivo_original = folder_name + '.zip'
|
||||||
|
failed_records.append({
|
||||||
|
"file": relative_path,
|
||||||
|
"archivo_original": archivo_original,
|
||||||
|
"error": f"Nomenclatura inválida: {folder_name}. Esperado: anio-aduana-patente-pedimento"
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
if match:
|
||||||
|
anio, aduana, patente, pedimento_num = match.groups()
|
||||||
|
try:
|
||||||
|
anio_completo = 2000 + int(anio) if int(anio) < 50 else 1900 + int(anio)
|
||||||
|
fecha_pago = datetime(anio_completo, 1, 1).date()
|
||||||
|
except ValueError:
|
||||||
|
failed_records.append({
|
||||||
|
"file": relative_path,
|
||||||
|
"archivo_original": folder_name + '.zip',
|
||||||
|
"error": f"Año inválido: {anio}"
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
elif match_sin_anio:
|
||||||
|
aduana, patente, pedimento_num = match_sin_anio.groups()
|
||||||
|
|
||||||
|
primer_digito_pedimento = int(pedimento_num[0]) if pedimento_num else 0
|
||||||
|
año_actual = datetime.now().year
|
||||||
|
año_con_digito = int(str(año_actual)[:-1] + str(primer_digito_pedimento))
|
||||||
|
|
||||||
|
if año_con_digito <= año_actual:
|
||||||
|
año_final = año_con_digito
|
||||||
|
else:
|
||||||
|
año_final = año_con_digito - 10
|
||||||
|
|
||||||
|
anio = año_final % 100
|
||||||
|
fecha_pago = datetime(año_final, 1, 1).date()
|
||||||
|
|
||||||
|
# Generar pedimento_app
|
||||||
|
pedimento_app = f"{anio}-{aduana.zfill(2)}-{patente}-{pedimento_num}"
|
||||||
|
|
||||||
|
# Verificar si el pedimento ya existe
|
||||||
|
existing_pedimento = Pedimento.objects.filter(
|
||||||
|
pedimento_app=pedimento_app,
|
||||||
|
organizacion=organizacion
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not existing_pedimento:
|
||||||
|
# Crear nuevo pedimento
|
||||||
|
try:
|
||||||
|
importador = None
|
||||||
|
if contribuyente:
|
||||||
|
importador, created = Importador.objects.get_or_create(
|
||||||
|
rfc=contribuyente,
|
||||||
|
defaults={
|
||||||
|
'nombre': f"Importador {contribuyente}",
|
||||||
|
'organizacion': organizacion
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
tipo_op = None
|
||||||
|
if tipo_operacion_input:
|
||||||
|
tipo_op = TipoOperacion.objects.get(id=tipo_operacion_input)
|
||||||
|
|
||||||
|
pedimento = Pedimento.objects.create(
|
||||||
|
organizacion=organizacion,
|
||||||
|
contribuyente=importador if importador else None,
|
||||||
|
pedimento=str(pedimento_num),
|
||||||
|
aduana=str(aduana),
|
||||||
|
patente=str(patente),
|
||||||
|
fecha_pago=fecha_pago_input if fecha_pago_input else fecha_pago,
|
||||||
|
curp_apoderado=curp_apoderado_input if curp_apoderado_input else "",
|
||||||
|
numero_partidas=partidas_input if partidas_input else 0,
|
||||||
|
tipo_operacion=tipo_op if tipo_op else None,
|
||||||
|
pedimento_app=pedimento_app,
|
||||||
|
agente_aduanal=f"Agente {patente}",
|
||||||
|
clave_pedimento=clave_pedimento_input if clave_pedimento_input else "A1"
|
||||||
|
)
|
||||||
|
|
||||||
|
existing_pedimento = pedimento
|
||||||
|
|
||||||
|
created_pedimentos.append({
|
||||||
|
"id": str(pedimento.id),
|
||||||
|
"pedimento_app": pedimento_app,
|
||||||
|
"contribuyente": getattr(importador, 'rfc', None),
|
||||||
|
"contribuyente_nombre": getattr(importador, 'nombre', None)
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
failed_records.append({
|
||||||
|
"file": relative_path,
|
||||||
|
"archivo_original": folder_name + '.zip',
|
||||||
|
"error": f"Error al crear pedimento: {str(e)}"
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
# Actualizar pedimento existente
|
||||||
|
if contribuyente:
|
||||||
|
importador, created = Importador.objects.get_or_create(
|
||||||
|
rfc=contribuyente,
|
||||||
|
defaults={
|
||||||
|
'nombre': f"Importador {contribuyente}",
|
||||||
|
'organizacion': organizacion
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
importador_db = existing_pedimento.contribuyente
|
||||||
|
if importador_db:
|
||||||
|
if importador_db != importador:
|
||||||
|
existing_pedimento.contribuyente = importador
|
||||||
|
else:
|
||||||
|
existing_pedimento.contribuyente = importador
|
||||||
|
|
||||||
|
existing_pedimento.save()
|
||||||
|
|
||||||
|
# Actualizar Tipo Operacion
|
||||||
|
if tipo_operacion_input:
|
||||||
|
tipo_op = TipoOperacion.objects.get(id=tipo_operacion_input)
|
||||||
|
if tipo_op and not existing_pedimento.tipo_operacion:
|
||||||
|
existing_pedimento.tipo_operacion = tipo_op
|
||||||
|
existing_pedimento.save()
|
||||||
|
|
||||||
|
# Actualizar fecha de pago
|
||||||
|
if fecha_pago_input:
|
||||||
|
fecha_db = existing_pedimento.fecha_pago
|
||||||
|
if fecha_db:
|
||||||
|
if isinstance(fecha_db, datetime):
|
||||||
|
fecha_db = fecha_db.date()
|
||||||
|
if fecha_db.month == 1 and fecha_db.day == 1:
|
||||||
|
existing_pedimento.fecha_pago = fecha_pago_input
|
||||||
|
existing_pedimento.save()
|
||||||
|
else:
|
||||||
|
existing_pedimento.fecha_pago = fecha_pago_input
|
||||||
|
existing_pedimento.save()
|
||||||
|
|
||||||
|
# Actualizar clave_pedimento
|
||||||
|
if clave_pedimento_input:
|
||||||
|
clave_pedimento = existing_pedimento.clave_pedimento
|
||||||
|
if not clave_pedimento or clave_pedimento.strip() != clave_pedimento_input.strip():
|
||||||
|
existing_pedimento.clave_pedimento = clave_pedimento_input
|
||||||
|
existing_pedimento.save()
|
||||||
|
|
||||||
|
# Actualizar curp_apoderado
|
||||||
|
if curp_apoderado_input:
|
||||||
|
if not existing_pedimento.curp_apoderado:
|
||||||
|
existing_pedimento.curp_apoderado = curp_apoderado_input
|
||||||
|
existing_pedimento.save()
|
||||||
|
|
||||||
|
# Actualizar partidas
|
||||||
|
if partidas_input:
|
||||||
|
num_partidas = existing_pedimento.numero_partidas
|
||||||
|
if not num_partidas or num_partidas <= 0:
|
||||||
|
existing_pedimento.numero_partidas = partidas_input
|
||||||
|
existing_pedimento.save()
|
||||||
|
|
||||||
|
# Crear documento asociado al pedimento
|
||||||
|
try:
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
file_content = f.read()
|
||||||
|
|
||||||
|
file_name_lower = file_name.lower()
|
||||||
|
tiene_nomenclatura_especial = False
|
||||||
|
info_extraida = {}
|
||||||
|
|
||||||
|
nombre_base, extension = os.path.splitext(file_name)
|
||||||
|
|
||||||
|
if patron_nomenclatura.match(file_name_lower):
|
||||||
|
tiene_nomenclatura_especial = True
|
||||||
|
info_extraida = procesar_archivo_m_con_nomenclatura(file_content, existing_pedimento)
|
||||||
|
|
||||||
|
# Buscar documento existente
|
||||||
|
existing_documents = Document.objects.filter(
|
||||||
|
pedimento_id=existing_pedimento.id,
|
||||||
|
organizacion=organizacion
|
||||||
|
)
|
||||||
|
|
||||||
|
existing_document = None
|
||||||
|
for doc in existing_documents:
|
||||||
|
if is_same_document(doc, file_name):
|
||||||
|
existing_document = doc
|
||||||
|
break
|
||||||
|
|
||||||
|
if existing_document:
|
||||||
|
updated_pedimentos.append({
|
||||||
|
"id": str(existing_pedimento.id),
|
||||||
|
"pedimento_app": existing_pedimento.pedimento_app,
|
||||||
|
"accion": "Documento ya existente, omitido",
|
||||||
|
"documento": file_name
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# Crear registro sin archivo primero
|
||||||
|
document = Document.objects.create(
|
||||||
|
organizacion=organizacion,
|
||||||
|
pedimento_id=existing_pedimento.id,
|
||||||
|
document_type=document_type,
|
||||||
|
fuente_id=fuente.id,
|
||||||
|
size=len(file_content),
|
||||||
|
extension=os.path.splitext(file_name)[1].lower().lstrip('.')
|
||||||
|
)
|
||||||
|
|
||||||
|
from api.utils.storage_service import storage_service
|
||||||
|
ruta = storage_service.save_document_from_path(
|
||||||
|
file_path=file_path,
|
||||||
|
file_name=file_name,
|
||||||
|
organizacion_id=organizacion.id,
|
||||||
|
pedimento_app=existing_pedimento.pedimento_app,
|
||||||
|
metadata={
|
||||||
|
'pedimento_id': str(existing_pedimento.id),
|
||||||
|
'document_id': str(document.id),
|
||||||
|
'source': 'bulk_upload_async'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if ruta:
|
||||||
|
document.archivo = ruta
|
||||||
|
document.save()
|
||||||
|
documents_created += 1
|
||||||
|
updated_pedimentos.append({
|
||||||
|
"id": str(existing_pedimento.id),
|
||||||
|
"pedimento_app": existing_pedimento.pedimento_app,
|
||||||
|
"accion": "Documento creado",
|
||||||
|
"documento": file_name
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
document.delete()
|
||||||
|
failed_records.append({
|
||||||
|
"file": relative_path,
|
||||||
|
"archivo_original": folder_name + '.zip',
|
||||||
|
"error": f"Error al guardar {file_name} en almacenamiento"
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
failed_records.append({
|
||||||
|
"file": relative_path,
|
||||||
|
"archivo_original": folder_name + '.zip',
|
||||||
|
"error": f"Error al crear documento: {str(e)}"
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Actualizar estado de expediente
|
||||||
|
if documents_created > 0 and existing_pedimento:
|
||||||
|
existing_pedimento.existe_expediente = True
|
||||||
|
existing_pedimento.save()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'status': 'completed',
|
||||||
|
'created_pedimentos': created_pedimentos,
|
||||||
|
'updated_pedimentos': updated_pedimentos,
|
||||||
|
'failed_records': failed_records,
|
||||||
|
'documents_created': documents_created,
|
||||||
|
'tieneError': len(failed_records) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error en bulk_upload_record_task: {str(e)}")
|
||||||
|
raise self.retry(exc=e, countdown=60)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Limpiar directorio temporal
|
||||||
|
if temp_dir and os.path.exists(temp_dir):
|
||||||
|
try:
|
||||||
|
shutil.rmtree(temp_dir)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error al limpiar directorio temporal: {e}")
|
||||||
@@ -1,6 +1,17 @@
|
|||||||
|
import logging
|
||||||
from celery import shared_task, group
|
from celery import shared_task, group
|
||||||
from api.customs.models import ProcesamientoPedimento, Pedimento, Cove, EDocument
|
from api.customs.models import ProcesamientoPedimento, Pedimento, Cove, EDocument
|
||||||
from core.utils import xml_controller
|
from core.utils import xml_controller
|
||||||
|
from core.redis_events import publish_task_event
|
||||||
|
from api.customs.tasks.auditoria import _crear_notificacion_auditoria
|
||||||
|
from api.customs.tasks.microservice import (
|
||||||
|
procesar_cove_individual,
|
||||||
|
procesar_acuse_individual,
|
||||||
|
procesar_acuse_cove_individual,
|
||||||
|
procesar_edoc_individual,
|
||||||
|
procesar_partida_individual,
|
||||||
|
procesar_remesa_individual,
|
||||||
|
)
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def crear_procesamiento_remesa(pedimento_id):
|
def crear_procesamiento_remesa(pedimento_id):
|
||||||
@@ -11,7 +22,7 @@ def crear_procesamiento_remesa(pedimento_id):
|
|||||||
if pedimento.remesas:
|
if pedimento.remesas:
|
||||||
existe = ProcesamientoPedimento.objects.filter(
|
existe = ProcesamientoPedimento.objects.filter(
|
||||||
pedimento=pedimento,
|
pedimento=pedimento,
|
||||||
servicio_id=5, # ID del servicio de remesas
|
servicio_id=5,
|
||||||
organizacion=pedimento.organizacion,
|
organizacion=pedimento.organizacion,
|
||||||
estado_id__in=[1, 2, 3, 4]
|
estado_id__in=[1, 2, 3, 4]
|
||||||
).exists()
|
).exists()
|
||||||
@@ -19,10 +30,11 @@ def crear_procesamiento_remesa(pedimento_id):
|
|||||||
logger.info(f"[TAREA] ProcesamientoPedimento remesa creado para pedimento {pedimento_id}")
|
logger.info(f"[TAREA] ProcesamientoPedimento remesa creado para pedimento {pedimento_id}")
|
||||||
ProcesamientoPedimento.objects.create(
|
ProcesamientoPedimento.objects.create(
|
||||||
pedimento=pedimento,
|
pedimento=pedimento,
|
||||||
estado_id=1, # Estado "pendiente"
|
estado_id=1,
|
||||||
servicio_id=5,
|
servicio_id=5,
|
||||||
organizacion=pedimento.organizacion
|
organizacion=pedimento.organizacion
|
||||||
)
|
)
|
||||||
|
procesar_remesa_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)])
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def crear_procesamiento_partida(pedimento_id):
|
def crear_procesamiento_partida(pedimento_id):
|
||||||
@@ -32,7 +44,7 @@ def crear_procesamiento_partida(pedimento_id):
|
|||||||
logger.info(f"[TAREA] crear_procesamiento_partida para pedimento {pedimento_id}")
|
logger.info(f"[TAREA] crear_procesamiento_partida para pedimento {pedimento_id}")
|
||||||
existe = ProcesamientoPedimento.objects.filter(
|
existe = ProcesamientoPedimento.objects.filter(
|
||||||
pedimento=pedimento,
|
pedimento=pedimento,
|
||||||
servicio_id=4, # ID del servicio de partidas
|
servicio_id=4,
|
||||||
organizacion=pedimento.organizacion,
|
organizacion=pedimento.organizacion,
|
||||||
estado_id__in=[1, 2, 3, 4]
|
estado_id__in=[1, 2, 3, 4]
|
||||||
).exists()
|
).exists()
|
||||||
@@ -40,10 +52,11 @@ def crear_procesamiento_partida(pedimento_id):
|
|||||||
logger.info(f"[TAREA] ProcesamientoPedimento partida creado para pedimento {pedimento_id}")
|
logger.info(f"[TAREA] ProcesamientoPedimento partida creado para pedimento {pedimento_id}")
|
||||||
ProcesamientoPedimento.objects.create(
|
ProcesamientoPedimento.objects.create(
|
||||||
pedimento=pedimento,
|
pedimento=pedimento,
|
||||||
estado_id=1, # Estado "pendiente"
|
estado_id=1,
|
||||||
servicio_id=4,
|
servicio_id=4,
|
||||||
organizacion=pedimento.organizacion
|
organizacion=pedimento.organizacion
|
||||||
)
|
)
|
||||||
|
procesar_partida_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)])
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def crear_procesamiento_cove(pedimento_id):
|
def crear_procesamiento_cove(pedimento_id):
|
||||||
@@ -54,7 +67,7 @@ def crear_procesamiento_cove(pedimento_id):
|
|||||||
if pedimento.coves.exists():
|
if pedimento.coves.exists():
|
||||||
existe = ProcesamientoPedimento.objects.filter(
|
existe = ProcesamientoPedimento.objects.filter(
|
||||||
pedimento=pedimento,
|
pedimento=pedimento,
|
||||||
servicio_id=8, # ID del servicio de Coves
|
servicio_id=8,
|
||||||
organizacion=pedimento.organizacion,
|
organizacion=pedimento.organizacion,
|
||||||
estado_id__in=[1, 2, 3, 4]
|
estado_id__in=[1, 2, 3, 4]
|
||||||
).exists()
|
).exists()
|
||||||
@@ -62,10 +75,11 @@ def crear_procesamiento_cove(pedimento_id):
|
|||||||
logger.info(f"[TAREA] ProcesamientoPedimento cove creado para pedimento {pedimento_id}")
|
logger.info(f"[TAREA] ProcesamientoPedimento cove creado para pedimento {pedimento_id}")
|
||||||
ProcesamientoPedimento.objects.create(
|
ProcesamientoPedimento.objects.create(
|
||||||
pedimento=pedimento,
|
pedimento=pedimento,
|
||||||
estado_id=1, # Estado "pendiente"
|
estado_id=1,
|
||||||
servicio_id=8,
|
servicio_id=8,
|
||||||
organizacion=pedimento.organizacion
|
organizacion=pedimento.organizacion
|
||||||
)
|
)
|
||||||
|
procesar_cove_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)])
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def crear_procesamiento_acuse(pedimento_id):
|
def crear_procesamiento_acuse(pedimento_id):
|
||||||
@@ -73,10 +87,10 @@ def crear_procesamiento_acuse(pedimento_id):
|
|||||||
logger = logging.getLogger('api.customs.async_operations')
|
logger = logging.getLogger('api.customs.async_operations')
|
||||||
pedimento = Pedimento.objects.get(id=pedimento_id)
|
pedimento = Pedimento.objects.get(id=pedimento_id)
|
||||||
logger.info(f"[TAREA] crear_procesamiento_acuse para pedimento {pedimento_id}")
|
logger.info(f"[TAREA] crear_procesamiento_acuse para pedimento {pedimento_id}")
|
||||||
if pedimento.coves.exists():
|
if pedimento.documentos.exists():
|
||||||
existe = ProcesamientoPedimento.objects.filter(
|
existe = ProcesamientoPedimento.objects.filter(
|
||||||
pedimento=pedimento,
|
pedimento=pedimento,
|
||||||
servicio_id=6, # ID del servicio de Acuse Cove
|
servicio_id=6,
|
||||||
organizacion=pedimento.organizacion,
|
organizacion=pedimento.organizacion,
|
||||||
estado_id__in=[1, 2, 3, 4]
|
estado_id__in=[1, 2, 3, 4]
|
||||||
).exists()
|
).exists()
|
||||||
@@ -84,10 +98,11 @@ def crear_procesamiento_acuse(pedimento_id):
|
|||||||
logger.info(f"[TAREA] ProcesamientoPedimento acuse creado para pedimento {pedimento_id}")
|
logger.info(f"[TAREA] ProcesamientoPedimento acuse creado para pedimento {pedimento_id}")
|
||||||
ProcesamientoPedimento.objects.create(
|
ProcesamientoPedimento.objects.create(
|
||||||
pedimento=pedimento,
|
pedimento=pedimento,
|
||||||
estado_id=1, # Estado "pendiente"
|
estado_id=1,
|
||||||
servicio_id=6,
|
servicio_id=6,
|
||||||
organizacion=pedimento.organizacion
|
organizacion=pedimento.organizacion
|
||||||
)
|
)
|
||||||
|
procesar_acuse_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)])
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def crear_procesamiento_acuse_cove(pedimento_id):
|
def crear_procesamiento_acuse_cove(pedimento_id):
|
||||||
@@ -98,7 +113,7 @@ def crear_procesamiento_acuse_cove(pedimento_id):
|
|||||||
if pedimento.coves.exists():
|
if pedimento.coves.exists():
|
||||||
existe = ProcesamientoPedimento.objects.filter(
|
existe = ProcesamientoPedimento.objects.filter(
|
||||||
pedimento=pedimento,
|
pedimento=pedimento,
|
||||||
servicio_id=9, # ID del servicio de Acuse Cove
|
servicio_id=9,
|
||||||
organizacion=pedimento.organizacion,
|
organizacion=pedimento.organizacion,
|
||||||
estado_id__in=[1, 2, 3, 4]
|
estado_id__in=[1, 2, 3, 4]
|
||||||
).exists()
|
).exists()
|
||||||
@@ -106,10 +121,11 @@ def crear_procesamiento_acuse_cove(pedimento_id):
|
|||||||
logger.info(f"[TAREA] ProcesamientoPedimento acuse_cove creado para pedimento {pedimento_id}")
|
logger.info(f"[TAREA] ProcesamientoPedimento acuse_cove creado para pedimento {pedimento_id}")
|
||||||
ProcesamientoPedimento.objects.create(
|
ProcesamientoPedimento.objects.create(
|
||||||
pedimento=pedimento,
|
pedimento=pedimento,
|
||||||
estado_id=1, # Estado "pendiente"
|
estado_id=1,
|
||||||
servicio_id=9,
|
servicio_id=9,
|
||||||
organizacion=pedimento.organizacion
|
organizacion=pedimento.organizacion
|
||||||
)
|
)
|
||||||
|
procesar_acuse_cove_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)])
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def crear_procesamiento_edocument(pedimento_id):
|
def crear_procesamiento_edocument(pedimento_id):
|
||||||
@@ -120,7 +136,7 @@ def crear_procesamiento_edocument(pedimento_id):
|
|||||||
if pedimento.documentos.exists():
|
if pedimento.documentos.exists():
|
||||||
existe = ProcesamientoPedimento.objects.filter(
|
existe = ProcesamientoPedimento.objects.filter(
|
||||||
pedimento=pedimento,
|
pedimento=pedimento,
|
||||||
servicio_id=7, # ID del servicio de EDocument
|
servicio_id=7,
|
||||||
organizacion=pedimento.organizacion,
|
organizacion=pedimento.organizacion,
|
||||||
estado_id__in=[1, 2, 3, 4]
|
estado_id__in=[1, 2, 3, 4]
|
||||||
).exists()
|
).exists()
|
||||||
@@ -128,10 +144,11 @@ def crear_procesamiento_edocument(pedimento_id):
|
|||||||
logger.info(f"[TAREA] ProcesamientoPedimento edocument creado para pedimento {pedimento_id}")
|
logger.info(f"[TAREA] ProcesamientoPedimento edocument creado para pedimento {pedimento_id}")
|
||||||
ProcesamientoPedimento.objects.create(
|
ProcesamientoPedimento.objects.create(
|
||||||
pedimento=pedimento,
|
pedimento=pedimento,
|
||||||
estado_id=1, # Estado "pendiente"
|
estado_id=1,
|
||||||
servicio_id=7,
|
servicio_id=7,
|
||||||
organizacion=pedimento.organizacion
|
organizacion=pedimento.organizacion
|
||||||
)
|
)
|
||||||
|
procesar_edoc_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)])
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def crear_procesamiento_pedimento_completo(organizacion_id):
|
def crear_procesamiento_pedimento_completo(organizacion_id):
|
||||||
@@ -166,51 +183,88 @@ def crear_servicios(organizacion_id):
|
|||||||
crear_procesamiento_acuse_cove.apply_async(args=[str(pedimento.id)])
|
crear_procesamiento_acuse_cove.apply_async(args=[str(pedimento.id)])
|
||||||
crear_procesamiento_edocument.apply_async(args=[str(pedimento.id)])
|
crear_procesamiento_edocument.apply_async(args=[str(pedimento.id)])
|
||||||
|
|
||||||
@shared_task
|
@shared_task(bind=True)
|
||||||
def auditar_pedimentos(organizacion_id):
|
def auditar_pedimentos(self, organizacion_id, user_id=None):
|
||||||
|
_logger = logging.getLogger('api.customs.async_operations')
|
||||||
|
task_id = self.request.id
|
||||||
|
|
||||||
pedimentos = Pedimento.objects.filter(organizacion_id=organizacion_id)
|
pedimentos = Pedimento.objects.filter(organizacion_id=organizacion_id)
|
||||||
for pedimento in pedimentos:
|
total_pedimentos = pedimentos.count()
|
||||||
|
|
||||||
|
publish_task_event(task_id, "processing", f"Auditando pedimentos: {total_pedimentos} pedimentos", progress=0)
|
||||||
|
|
||||||
|
procesados = 0
|
||||||
|
sin_xml = 0
|
||||||
|
errores = []
|
||||||
|
|
||||||
|
for idx, pedimento in enumerate(pedimentos):
|
||||||
pc = pedimento.documents.filter(document_type__id=2).first()
|
pc = pedimento.documents.filter(document_type__id=2).first()
|
||||||
if pc:
|
if pc:
|
||||||
with open(f'./media/{pc.archivo}', 'r') as f:
|
|
||||||
xml_content = f.read()
|
|
||||||
|
|
||||||
xml_data = xml_controller.extract_data(xml_content)
|
|
||||||
|
|
||||||
pedimento.numero_operacion = xml_data.get('numero_operacion')
|
|
||||||
pedimento.curp_apoderado = xml_data.get('curp_apoderado')
|
|
||||||
pedimento.agente_aduanal = xml_data.get('agente_aduanal')
|
|
||||||
pedimento.numero_partidas = xml_data.get('numero_partidas')
|
|
||||||
pedimento.remesas = xml_data.get('remesas')
|
|
||||||
pedimento.tipo_operacion__id = xml_data.get('tipo_operacion')
|
|
||||||
pedimento.fecha_pago = xml_data.get('fecha_pago')
|
|
||||||
pedimento.pedimento_app = xml_data.get('fecha_pago')[2:4] + "-" + pedimento.aduana[:2] + "-" + pedimento.patente + "-" + pedimento.pedimentodd
|
|
||||||
|
|
||||||
for edoc in xml_data.get('edocuments', []):
|
|
||||||
EDocument.objects.get_or_create(
|
|
||||||
pedimento=pedimento,
|
|
||||||
organizacion=pedimento.organizacion,
|
|
||||||
clave=edoc.get('clave'),
|
|
||||||
descripcion=edoc.get('descripcion'),
|
|
||||||
numero_edocument=edoc.get('complemento1')
|
|
||||||
)
|
|
||||||
|
|
||||||
from django.db import IntegrityError
|
|
||||||
try:
|
try:
|
||||||
for cove in xml_data.get('coves', []):
|
with open(f'./media/{pc.archivo}', 'r') as f:
|
||||||
try:
|
xml_content = f.read()
|
||||||
Cove.objects.get_or_create(
|
|
||||||
pedimento=pedimento,
|
xml_data = xml_controller.extract_data(xml_content)
|
||||||
organizacion=pedimento.organizacion,
|
|
||||||
numero_cove=cove
|
pedimento.numero_operacion = xml_data.get('numero_operacion')
|
||||||
)
|
pedimento.curp_apoderado = xml_data.get('curp_apoderado')
|
||||||
except IntegrityError:
|
pedimento.agente_aduanal = xml_data.get('agente_aduanal')
|
||||||
# Si ya existe por unique, recupera el objeto existente
|
pedimento.numero_partidas = xml_data.get('numero_partidas')
|
||||||
Cove.objects.get(numero_cove=cove)
|
pedimento.remesas = xml_data.get('remesas')
|
||||||
except:
|
pedimento.tipo_operacion__id = xml_data.get('tipo_operacion')
|
||||||
# Si ya existe por unique, recupera el objeto existente
|
pedimento.fecha_pago = xml_data.get('fecha_pago')
|
||||||
pass
|
pedimento.pedimento_app = xml_data.get('fecha_pago')[2:4] + "-" + pedimento.aduana[:2] + "-" + pedimento.patente + "-" + pedimento.pedimentodd
|
||||||
|
|
||||||
|
for edoc in xml_data.get('identificadores_ed', []):
|
||||||
|
EDocument.objects.get_or_create(
|
||||||
|
pedimento=pedimento,
|
||||||
|
organizacion=pedimento.organizacion,
|
||||||
|
clave=edoc.get('clave'),
|
||||||
|
descripcion=edoc.get('descripcion'),
|
||||||
|
numero_edocument=edoc.get('complemento1')
|
||||||
|
)
|
||||||
|
|
||||||
|
from django.db import IntegrityError
|
||||||
|
try:
|
||||||
|
for cove in xml_data.get('coves', []):
|
||||||
|
try:
|
||||||
|
Cove.objects.get_or_create(
|
||||||
|
pedimento=pedimento,
|
||||||
|
organizacion=pedimento.organizacion,
|
||||||
|
numero_cove=cove
|
||||||
|
)
|
||||||
|
except IntegrityError:
|
||||||
|
# Si ya existe por unique, recupera el objeto existente
|
||||||
|
Cove.objects.get(numero_cove=cove)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
procesados += 1
|
||||||
|
except Exception as e:
|
||||||
|
errores.append({'pedimento_id': str(pedimento.id), 'error': str(e)})
|
||||||
|
_logger.error(f"Error auditando pedimento {pedimento.id}: {e}")
|
||||||
|
else:
|
||||||
|
sin_xml += 1
|
||||||
|
|
||||||
|
if total_pedimentos > 0 and (idx + 1) % 10 == 0:
|
||||||
|
pct = int(((idx + 1) / total_pedimentos) * 100)
|
||||||
|
publish_task_event(task_id, "processing", f"Auditando pedimentos: {idx + 1}/{total_pedimentos}", progress=pct)
|
||||||
|
|
||||||
|
resultado = {
|
||||||
|
'organizacion_id': str(organizacion_id),
|
||||||
|
'auditoria': 'pedimentos',
|
||||||
|
'total_pedimentos': total_pedimentos,
|
||||||
|
'procesados': procesados,
|
||||||
|
'sin_xml': sin_xml,
|
||||||
|
'con_errores': len(errores),
|
||||||
|
'detalle_errores': errores,
|
||||||
|
}
|
||||||
|
|
||||||
|
publish_task_event(task_id, "completed", "Auditoría de pedimentos completada", resultado=resultado, progress=100)
|
||||||
|
if user_id:
|
||||||
|
_crear_notificacion_auditoria(user_id, task_id, "Pedimentos", resultado)
|
||||||
|
|
||||||
|
return resultado
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def crear_todos_los_servicios():
|
def crear_todos_los_servicios():
|
||||||
|
|||||||
@@ -1,24 +1,47 @@
|
|||||||
|
from api.organization.models import Organizacion
|
||||||
from celery import group
|
from celery import group
|
||||||
from celery import shared_task, group
|
from celery import shared_task, group
|
||||||
from api.customs.models import *
|
from api.customs.models import *
|
||||||
from api.record.models import *
|
from api.record.models import *
|
||||||
from api.customs.serializers import PedimentoSerializer
|
from api.customs.serializers import PedimentoSerializer
|
||||||
from api.vucem.models import *
|
from api.vucem.models import *
|
||||||
|
from django.db.models import F
|
||||||
|
from django.utils import timezone
|
||||||
import requests
|
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
|
from datetime import datetime
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
# este solo fue para pruebas personales, lo dejo por si en un futuro lo requiero
|
||||||
|
TEST_ORG_ID = uuid.UUID('defc7848-4f39-4d67-9dba-5bb445248d23')
|
||||||
|
logger = logging.getLogger('api.customs.microservice_v2')
|
||||||
|
|
||||||
def credenciales_to_dict(credenciales):
|
def credenciales_to_dict(credenciales):
|
||||||
if not credenciales:
|
if not credenciales:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
key_value = None
|
||||||
|
if credenciales.key:
|
||||||
|
if hasattr(credenciales.key, 'url'):
|
||||||
|
key_value = credenciales.key.url
|
||||||
|
else:
|
||||||
|
key_value = str(credenciales.key)
|
||||||
|
|
||||||
|
cer_value = None
|
||||||
|
if credenciales.cer:
|
||||||
|
if hasattr(credenciales.cer, 'url'):
|
||||||
|
cer_value = credenciales.cer.url
|
||||||
|
else:
|
||||||
|
cer_value = str(credenciales.cer)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": str(credenciales.id),
|
"id": str(credenciales.id),
|
||||||
"user": credenciales.usuario,
|
"user": credenciales.usuario,
|
||||||
"password": credenciales.password,
|
"password": credenciales.password,
|
||||||
"efirma": credenciales.efirma,
|
"efirma": credenciales.efirma,
|
||||||
"key": credenciales.key.url if credenciales.key else None,
|
"key": key_value,
|
||||||
"cer": credenciales.cer.url if credenciales.cer else None,
|
"cer": cer_value,
|
||||||
"is_active": credenciales.is_active,
|
"is_active": credenciales.is_active,
|
||||||
"organizacion": str(credenciales.organizacion.id) if credenciales.organizacion else None,
|
"organizacion": str(credenciales.organizacion.id) if credenciales.organizacion else None,
|
||||||
}
|
}
|
||||||
@@ -56,8 +79,10 @@ def partida_to_dict(partida):
|
|||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def procesar_coves_pedimento(pedimento_id):
|
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)
|
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)
|
pedimento_dict = pedimento_to_dict(pedimento)
|
||||||
credenciales = Vucem.objects.filter(
|
credenciales = Vucem.objects.filter(
|
||||||
id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id
|
id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id
|
||||||
@@ -65,22 +90,30 @@ def procesar_coves_pedimento(pedimento_id):
|
|||||||
credenciales_dict = credenciales_to_dict(credenciales)
|
credenciales_dict = credenciales_to_dict(credenciales)
|
||||||
|
|
||||||
payload = {
|
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,
|
"pedimento": pedimento_dict,
|
||||||
"credencial": credenciales_dict
|
"credencial": credenciales_dict
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.post(
|
try:
|
||||||
f"{SERVICE_API_URL_V2}/services/all/coves",
|
response = requests.post(
|
||||||
data=json.dumps(payload),
|
f"{SERVICE_API_URL_V2}/services/all/coves",
|
||||||
headers={"Content-Type": "application/json"}
|
data=json.dumps(payload),
|
||||||
)
|
headers={"Content-Type": "application/json"},
|
||||||
print(f"Servicio de COVEs enviado para pedimento {pedimento.pedimento}")
|
timeout=60
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
logging.info(f"COVEs encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logging.error(f"Error encolando COVEs para pedimento {pedimento.pedimento}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def procesar_acuse_coves_pedimento(pedimento_id):
|
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)
|
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)
|
pedimento_dict = pedimento_to_dict(pedimento)
|
||||||
credenciales = Vucem.objects.filter(
|
credenciales = Vucem.objects.filter(
|
||||||
id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id
|
id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id
|
||||||
@@ -88,22 +121,30 @@ def procesar_acuse_coves_pedimento(pedimento_id):
|
|||||||
credenciales_dict = credenciales_to_dict(credenciales)
|
credenciales_dict = credenciales_to_dict(credenciales)
|
||||||
|
|
||||||
payload = {
|
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,
|
"pedimento": pedimento_dict,
|
||||||
"credencial": credenciales_dict
|
"credencial": credenciales_dict
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.post(
|
try:
|
||||||
f"{SERVICE_API_URL_V2}/services/all/acuse/cove/",
|
response = requests.post(
|
||||||
data=json.dumps(payload),
|
f"{SERVICE_API_URL_V2}/services/all/acuse/cove/",
|
||||||
headers={"Content-Type": "application/json"}
|
data=json.dumps(payload),
|
||||||
)
|
headers={"Content-Type": "application/json"},
|
||||||
print(f"Servicio de acuses de COVEs enviado para pedimento {pedimento.pedimento}")
|
timeout=60
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
logging.info(f"Acuses de COVEs encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logging.error(f"Error encolando acuses de COVEs para pedimento {pedimento.pedimento}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def procesar_edocs_pedimento(pedimento_id):
|
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)
|
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)
|
pedimento_dict = pedimento_to_dict(pedimento)
|
||||||
credenciales = Vucem.objects.filter(
|
credenciales = Vucem.objects.filter(
|
||||||
id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id
|
id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id
|
||||||
@@ -111,22 +152,30 @@ def procesar_edocs_pedimento(pedimento_id):
|
|||||||
credenciales_dict = credenciales_to_dict(credenciales)
|
credenciales_dict = credenciales_to_dict(credenciales)
|
||||||
|
|
||||||
payload = {
|
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,
|
"pedimento": pedimento_dict,
|
||||||
"credencial": credenciales_dict
|
"credencial": credenciales_dict
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.post(
|
try:
|
||||||
f"{SERVICE_API_URL_V2}/services/download/edoc/",
|
response = requests.post(
|
||||||
data=json.dumps(payload),
|
f"{SERVICE_API_URL_V2}/services/download/all/edocs/",
|
||||||
headers={"Content-Type": "application/json"}
|
data=json.dumps(payload),
|
||||||
)
|
headers={"Content-Type": "application/json"},
|
||||||
print(f"Servicio de E-documents enviado para pedimento {pedimento.pedimento}")
|
timeout=60
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
logging.info(f"E-documents encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logging.error(f"Error encolando E-documents para pedimento {pedimento.pedimento}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def procesar_acuses_pedimento(pedimento_id):
|
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)
|
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)
|
pedimento_dict = pedimento_to_dict(pedimento)
|
||||||
credenciales = Vucem.objects.filter(
|
credenciales = Vucem.objects.filter(
|
||||||
id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id
|
id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id
|
||||||
@@ -134,17 +183,23 @@ def procesar_acuses_pedimento(pedimento_id):
|
|||||||
credenciales_dict = credenciales_to_dict(credenciales)
|
credenciales_dict = credenciales_to_dict(credenciales)
|
||||||
|
|
||||||
payload = {
|
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,
|
"pedimento": pedimento_dict,
|
||||||
"credencial": credenciales_dict
|
"credencial": credenciales_dict
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.post(
|
try:
|
||||||
f"{SERVICE_API_URL_V2}/services/all/acuse/pedimento/",
|
response = requests.post(
|
||||||
data=json.dumps(payload),
|
f"{SERVICE_API_URL_V2}/services/all/acuse/pedimento/",
|
||||||
headers={"Content-Type": "application/json"}
|
data=json.dumps(payload),
|
||||||
)
|
headers={"Content-Type": "application/json"},
|
||||||
print(f"Servicio de acuses enviado para pedimento {pedimento.pedimento}")
|
timeout=60
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
logging.info(f"Acuses encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logging.error(f"Error encolando acuses para pedimento {pedimento.pedimento}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def procesar_partidas_pedimento(pedimento_id):
|
def procesar_partidas_pedimento(pedimento_id):
|
||||||
@@ -156,18 +211,31 @@ def procesar_partidas_pedimento(pedimento_id):
|
|||||||
).first()
|
).first()
|
||||||
credenciales_dict = credenciales_to_dict(credenciales)
|
credenciales_dict = credenciales_to_dict(credenciales)
|
||||||
|
|
||||||
|
partidas_pendientes = list(pedimento.partidas.filter(descargado=False))
|
||||||
payload = {
|
payload = {
|
||||||
"partidas": [partida_to_dict(partida) for partida in pedimento.partidas.filter(descargado=False)],
|
"partidas": [partida_to_dict(p) for p in partidas_pendientes],
|
||||||
"pedimento": pedimento_dict,
|
"pedimento": pedimento_dict,
|
||||||
"credencial": credenciales_dict
|
"credencial": credenciales_dict
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.post(
|
try:
|
||||||
f"{SERVICE_API_URL_V2}/services/all/partidas/",
|
response = requests.post(
|
||||||
data=json.dumps(payload),
|
f"{SERVICE_API_URL_V2}/services/all/partidas/",
|
||||||
headers={"Content-Type": "application/json"}
|
data=json.dumps(payload),
|
||||||
)
|
headers={"Content-Type": "application/json"},
|
||||||
print(f"Servicio de partidas enviado para pedimento {pedimento.pedimento}")
|
timeout=60
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
result = response.json()
|
||||||
|
logging.info(
|
||||||
|
f"Partidas encoladas para pedimento {pedimento.pedimento}: "
|
||||||
|
f"{result.get('total', 0)} de {len(partidas_pendientes)}"
|
||||||
|
)
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logging.error(
|
||||||
|
f"Error encolando partidas para pedimento {pedimento.pedimento}: {e}"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def procesar_remesas_pedimento(pedimento_id):
|
def procesar_remesas_pedimento(pedimento_id):
|
||||||
@@ -184,17 +252,23 @@ def procesar_remesas_pedimento(pedimento_id):
|
|||||||
"credencial": credenciales_dict
|
"credencial": credenciales_dict
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.post(
|
try:
|
||||||
f"{SERVICE_API_URL_V2}/services/remesas",
|
response = requests.post(
|
||||||
data=json.dumps(payload),
|
f"{SERVICE_API_URL_V2}/services/remesas",
|
||||||
headers={"Content-Type": "application/json"}
|
data=json.dumps(payload),
|
||||||
)
|
headers={"Content-Type": "application/json"},
|
||||||
print(f"Servicio de remesas enviado para pedimento {pedimento.pedimento}")
|
timeout=60
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
logging.info(f"Remesa encolada para pedimento {pedimento.pedimento}")
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logging.error(f"Error encolando remesa para pedimento {pedimento.pedimento}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def procesar_pedimento_completo_individual(pedimento_id):
|
def procesar_pedimento_completo_individual(pedimento_id, force=False):
|
||||||
pedimento = Pedimento.objects.get(id=pedimento_id)
|
pedimento = Pedimento.objects.get(id=pedimento_id)
|
||||||
if not pedimento.documents.filter(document_type=2).exists(): # Tipo 2: Pedimento Completo
|
if force or not pedimento.documents.filter(document_type=2).exists(): # Tipo 2: Pedimento Completo
|
||||||
pedimento_dict = pedimento_to_dict(pedimento)
|
pedimento_dict = pedimento_to_dict(pedimento)
|
||||||
credenciales = Vucem.objects.filter(
|
credenciales = Vucem.objects.filter(
|
||||||
id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id
|
id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id
|
||||||
@@ -204,23 +278,43 @@ def procesar_pedimento_completo_individual(pedimento_id):
|
|||||||
"pedimento": pedimento_dict,
|
"pedimento": pedimento_dict,
|
||||||
"credencial": credenciales_dict
|
"credencial": credenciales_dict
|
||||||
}
|
}
|
||||||
response = requests.post(
|
try:
|
||||||
f"{SERVICE_API_URL_V2}/services/pedimento_completo",
|
response = requests.post(
|
||||||
data=json.dumps(payload),
|
f"{SERVICE_API_URL_V2}/services/pedimento_completo",
|
||||||
headers={"Content-Type": "application/json"}
|
data=json.dumps(payload),
|
||||||
)
|
headers={"Content-Type": "application/json"},
|
||||||
print(f"Servicio enviado para pedimento {pedimento.pedimento}")
|
timeout=60
|
||||||
return response
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
logging.info(f"Pedimento completo encolado: {pedimento.pedimento}")
|
||||||
|
return response
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logging.error(f"Error encolando pedimento completo {pedimento.pedimento}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def procesar_pedimentos_completos(organizacion_id):
|
def procesar_pedimentos_completos(organizacion_id):
|
||||||
pedimentos = Pedimento.objects.filter(organizacion_id=organizacion_id)
|
pedimentos = Pedimento.objects.filter(organizacion_id=organizacion_id)
|
||||||
respuestas = []
|
respuestas = []
|
||||||
for pedimento in pedimentos:
|
for pedimento in pedimentos:
|
||||||
|
|
||||||
|
if not pedimento.contribuyente:
|
||||||
|
print(f"Pedimento {pedimento.pedimento} no tiene contribuyente")
|
||||||
|
continue
|
||||||
|
|
||||||
|
credencial_importador = CredencialesImportador.objects.filter(
|
||||||
|
rfc=pedimento.contribuyente
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not credencial_importador:
|
||||||
|
print(f"No credencial para RFC {pedimento.contribuyente.rfc}")
|
||||||
|
continue
|
||||||
|
|
||||||
if not pedimento.documents.filter(document_type=2).exists(): # Tipo 2: Pedimento Completo
|
if not pedimento.documents.filter(document_type=2).exists(): # Tipo 2: Pedimento Completo
|
||||||
# Convertir el pedimento a JSON usando el serializer
|
# Convertir el pedimento a JSON usando el serializer
|
||||||
pedimento_dict = pedimento_to_dict(pedimento)
|
pedimento_dict = pedimento_to_dict(pedimento)
|
||||||
credenciales = Vucem.objects.filter(id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id).first()
|
# credenciales = Vucem.objects.filter(id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id).first()
|
||||||
|
credenciales = Vucem.objects.filter(id=credencial_importador.vucem.id).first()
|
||||||
|
|
||||||
if not credenciales:
|
if not credenciales:
|
||||||
print(f"No se encontraron credenciales para el pedimento {pedimento.pedimento_app}")
|
print(f"No se encontraron credenciales para el pedimento {pedimento.pedimento_app}")
|
||||||
@@ -231,23 +325,45 @@ def procesar_pedimentos_completos(organizacion_id):
|
|||||||
"pedimento": pedimento_dict,
|
"pedimento": pedimento_dict,
|
||||||
"credencial": credenciales_dict
|
"credencial": credenciales_dict
|
||||||
}
|
}
|
||||||
response = requests.post(
|
|
||||||
f"{SERVICE_API_URL_V2}/services/pedimento_completo",
|
url = f"{SERVICE_API_URL_V2}/services/pedimento_completo"
|
||||||
data=json.dumps(payload),
|
dataJson = json.dumps(payload)
|
||||||
headers={"Content-Type": "application/json"}
|
|
||||||
)
|
try:
|
||||||
# Aquí puedes continuar con el resto de tu lógica
|
response = requests.post(
|
||||||
print(f"Servicio enviado para pedimento {pedimento.pedimento}")
|
url,
|
||||||
|
data=dataJson,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
timeout=60
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
logging.info(f"Pedimento completo encolado: {pedimento.pedimento}")
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logging.error(f"Error encolando pedimento completo {pedimento.pedimento}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def procesar_remesas(organizacion_id):
|
def procesar_remesas(organizacion_id):
|
||||||
pedimentos = Pedimento.objects.filter(organizacion_id=organizacion_id)
|
pedimentos = Pedimento.objects.filter(organizacion_id=organizacion_id)
|
||||||
|
|
||||||
for pedimento in pedimentos:
|
for pedimento in pedimentos:
|
||||||
if not pedimento.documents.filter(document_type=3).exists(): # Tipo 3: Remesa
|
logger.info(f"pedimento >>>> {pedimento}")
|
||||||
# Convertir el pedimento a JSON usando el serializer
|
try:
|
||||||
|
# if pedimento.documents.filter(document_type=3).exists(): # Remesa ya descargada
|
||||||
|
# logger.info(f"Pedimento {pedimento.pedimento} ya tiene remesa descargada, omitiendo.")
|
||||||
|
# continue
|
||||||
|
|
||||||
pedimento_dict = pedimento_to_dict(pedimento)
|
pedimento_dict = pedimento_to_dict(pedimento)
|
||||||
credenciales = Vucem.objects.filter(id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id).first()
|
|
||||||
|
credencial_importador = CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first()
|
||||||
|
if not credencial_importador:
|
||||||
|
logger.warning(f"Sin credenciales para RFC {pedimento.contribuyente} (pedimento {pedimento.pedimento}), omitiendo.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
credenciales = Vucem.objects.filter(id=credencial_importador.vucem.id).first()
|
||||||
|
if not credenciales:
|
||||||
|
logger.warning(f"Credencial Vucem no encontrada para pedimento {pedimento.pedimento}, omitiendo.")
|
||||||
|
continue
|
||||||
|
|
||||||
credenciales_dict = credenciales_to_dict(credenciales)
|
credenciales_dict = credenciales_to_dict(credenciales)
|
||||||
|
|
||||||
@@ -256,15 +372,17 @@ def procesar_remesas(organizacion_id):
|
|||||||
"credencial": credenciales_dict
|
"credencial": credenciales_dict
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
f"{SERVICE_API_URL_V2}/services/remesas",
|
f"{SERVICE_API_URL_V2}/services/remesas/",
|
||||||
data=json.dumps(payload),
|
data=json.dumps(payload),
|
||||||
headers={"Content-Type": "application/json"}
|
headers={"Content-Type": "application/json"},
|
||||||
|
timeout=60
|
||||||
)
|
)
|
||||||
# Aquí puedes continuar con el resto de tu lógica
|
response.raise_for_status()
|
||||||
|
logger.info(f"Remesa encolada para pedimento {pedimento.pedimento} — status {response.status_code}")
|
||||||
|
|
||||||
print(f"Servicio enviado para pedimento {pedimento.pedimento}")
|
except Exception as e:
|
||||||
|
logger.error(f"Error procesando remesa para pedimento {pedimento.pedimento}: {e}", exc_info=True)
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def procesar_coves(organizacion_id):
|
def procesar_coves(organizacion_id):
|
||||||
@@ -273,7 +391,14 @@ def procesar_coves(organizacion_id):
|
|||||||
coves__isnull=False
|
coves__isnull=False
|
||||||
).distinct()
|
).distinct()
|
||||||
for pedimento in pedimentos:
|
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
|
# Convertir el pedimento a JSON usando el serializer
|
||||||
pedimento_dict = pedimento_to_dict(pedimento)
|
pedimento_dict = pedimento_to_dict(pedimento)
|
||||||
@@ -282,19 +407,27 @@ def procesar_coves(organizacion_id):
|
|||||||
credenciales_dict = credenciales_to_dict(credenciales)
|
credenciales_dict = credenciales_to_dict(credenciales)
|
||||||
|
|
||||||
payload = {
|
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,
|
"pedimento": pedimento_dict,
|
||||||
"credencial": credenciales_dict
|
"credencial": credenciales_dict
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.post(
|
# Un ciclo de orquestación = un intento; los reintentos internos
|
||||||
f"{SERVICE_API_URL_V2}/services/all/coves",
|
# del worker (Celery/SOAP) pertenecen a este mismo intento
|
||||||
data=json.dumps(payload),
|
pendientes.update(cove_intentos=F('cove_intentos') + 1, ultimo_intento_at=timezone.now())
|
||||||
headers={"Content-Type": "application/json"}
|
|
||||||
)
|
|
||||||
# Aquí puedes continuar con el resto de tu lógica
|
|
||||||
|
|
||||||
print(f"Servicio enviado para pedimento {pedimento.pedimento}")
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
f"{SERVICE_API_URL_V2}/services/all/coves",
|
||||||
|
data=json.dumps(payload),
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
timeout=60
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
logging.info(f"COVEs encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logging.error(f"Error encolando COVEs para pedimento {pedimento.pedimento}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def procesar_acuse_coves(organizacion_id):
|
def procesar_acuse_coves(organizacion_id):
|
||||||
@@ -304,7 +437,13 @@ def procesar_acuse_coves(organizacion_id):
|
|||||||
).distinct()
|
).distinct()
|
||||||
|
|
||||||
for pedimento in pedimentos:
|
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
|
# Convertir el pedimento a JSON usando el serializer
|
||||||
pedimento_dict = pedimento_to_dict(pedimento)
|
pedimento_dict = pedimento_to_dict(pedimento)
|
||||||
@@ -313,19 +452,26 @@ def procesar_acuse_coves(organizacion_id):
|
|||||||
credenciales_dict = credenciales_to_dict(credenciales)
|
credenciales_dict = credenciales_to_dict(credenciales)
|
||||||
|
|
||||||
payload = {
|
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,
|
"pedimento": pedimento_dict,
|
||||||
"credencial": credenciales_dict
|
"credencial": credenciales_dict
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.post(
|
# Un ciclo de orquestación = un intento
|
||||||
f"{SERVICE_API_URL_V2}/services/all/acuse/cove/",
|
pendientes.update(acuse_cove_intentos=F('acuse_cove_intentos') + 1, ultimo_intento_at=timezone.now())
|
||||||
data=json.dumps(payload),
|
|
||||||
headers={"Content-Type": "application/json"}
|
|
||||||
)
|
|
||||||
# Aquí puedes continuar con el resto de tu lógica
|
|
||||||
|
|
||||||
print(f"Servicio enviado para pedimento {pedimento.pedimento}")
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
f"{SERVICE_API_URL_V2}/services/all/acuse/cove/",
|
||||||
|
data=json.dumps(payload),
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
timeout=60
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
logging.info(f"Acuses de COVEs encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logging.error(f"Error encolando acuses de COVEs para pedimento {pedimento.pedimento}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def procesar_acuses(organizacion_id):
|
def procesar_acuses(organizacion_id):
|
||||||
@@ -335,7 +481,13 @@ def procesar_acuses(organizacion_id):
|
|||||||
).distinct()
|
).distinct()
|
||||||
|
|
||||||
for pedimento in pedimentos:
|
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
|
# Convertir el pedimento a JSON usando el serializer
|
||||||
pedimento_dict = pedimento_to_dict(pedimento)
|
pedimento_dict = pedimento_to_dict(pedimento)
|
||||||
@@ -344,19 +496,26 @@ def procesar_acuses(organizacion_id):
|
|||||||
credenciales_dict = credenciales_to_dict(credenciales)
|
credenciales_dict = credenciales_to_dict(credenciales)
|
||||||
|
|
||||||
payload = {
|
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,
|
"pedimento": pedimento_dict,
|
||||||
"credencial": credenciales_dict
|
"credencial": credenciales_dict
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.post(
|
# Un ciclo de orquestación = un intento
|
||||||
f"{SERVICE_API_URL_V2}/services/all/acuse/pedimento/",
|
pendientes.update(acuse_intentos=F('acuse_intentos') + 1, ultimo_intento_at=timezone.now())
|
||||||
data=json.dumps(payload),
|
|
||||||
headers={"Content-Type": "application/json"}
|
|
||||||
)
|
|
||||||
# Aquí puedes continuar con el resto de tu lógica
|
|
||||||
|
|
||||||
print(f"Servicio enviado para pedimento {pedimento.pedimento}")
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
f"{SERVICE_API_URL_V2}/services/all/acuse/pedimento/",
|
||||||
|
data=json.dumps(payload),
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
timeout=60
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
logging.info(f"Acuses encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logging.error(f"Error encolando acuses para pedimento {pedimento.pedimento}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def procesar_edocs(organizacion_id):
|
def procesar_edocs(organizacion_id):
|
||||||
@@ -366,7 +525,13 @@ def procesar_edocs(organizacion_id):
|
|||||||
).distinct()
|
).distinct()
|
||||||
|
|
||||||
for pedimento in pedimentos:
|
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
|
# Convertir el pedimento a JSON usando el serializer
|
||||||
pedimento_dict = pedimento_to_dict(pedimento)
|
pedimento_dict = pedimento_to_dict(pedimento)
|
||||||
@@ -375,19 +540,26 @@ def procesar_edocs(organizacion_id):
|
|||||||
credenciales_dict = credenciales_to_dict(credenciales)
|
credenciales_dict = credenciales_to_dict(credenciales)
|
||||||
|
|
||||||
payload = {
|
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,
|
"pedimento": pedimento_dict,
|
||||||
"credencial": credenciales_dict
|
"credencial": credenciales_dict
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.post(
|
# Un ciclo de orquestación = un intento
|
||||||
f"{SERVICE_API_URL_V2}/services/download/all/edocs/",
|
pendientes.update(edocument_intentos=F('edocument_intentos') + 1, ultimo_intento_at=timezone.now())
|
||||||
data=json.dumps(payload),
|
|
||||||
headers={"Content-Type": "application/json"}
|
|
||||||
)
|
|
||||||
# Aquí puedes continuar con el resto de tu lógica
|
|
||||||
|
|
||||||
print(f"Servicio enviado para pedimento {pedimento.pedimento}")
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
f"{SERVICE_API_URL_V2}/services/download/all/edocs/",
|
||||||
|
data=json.dumps(payload),
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
timeout=60
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
logging.info(f"E-documents encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logging.error(f"Error encolando E-documents para pedimento {pedimento.pedimento}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def procesar_partidas(organizacion_id):
|
def procesar_partidas(organizacion_id):
|
||||||
@@ -397,27 +569,40 @@ def procesar_partidas(organizacion_id):
|
|||||||
).distinct()
|
).distinct()
|
||||||
|
|
||||||
for pedimento in pedimentos:
|
for pedimento in pedimentos:
|
||||||
if pedimento.partidas.filter(descargado=False).exists(): # Tipo 4: Partidas
|
partidas_pendientes = list(pedimento.partidas.filter(descargado=False))
|
||||||
# Convertir el pedimento a JSON usando el serializer
|
if not partidas_pendientes:
|
||||||
pedimento_dict = pedimento_to_dict(pedimento)
|
continue
|
||||||
credenciales = Vucem.objects.filter(id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id).first()
|
|
||||||
|
|
||||||
credenciales_dict = credenciales_to_dict(credenciales)
|
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 = {
|
payload = {
|
||||||
"partidas": [partida_to_dict(partida) for partida in pedimento.partidas.filter(descargado=False)],
|
"partidas": [partida_to_dict(p) for p in partidas_pendientes],
|
||||||
"pedimento": pedimento_dict,
|
"pedimento": pedimento_dict,
|
||||||
"credencial": credenciales_dict
|
"credencial": credenciales_dict
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
f"{SERVICE_API_URL_V2}/services/all/partidas/",
|
f"{SERVICE_API_URL_V2}/services/all/partidas/",
|
||||||
data=json.dumps(payload),
|
data=json.dumps(payload),
|
||||||
headers={"Content-Type": "application/json"}
|
headers={"Content-Type": "application/json"},
|
||||||
|
timeout=60
|
||||||
)
|
)
|
||||||
# Aquí puedes continuar con el resto de tu lógica
|
response.raise_for_status()
|
||||||
|
result = response.json()
|
||||||
print(f"Servicio enviado para pedimento {pedimento.pedimento}")
|
logging.info(
|
||||||
|
f"Partidas encoladas para pedimento {pedimento.pedimento}: "
|
||||||
|
f"{result.get('total', 0)} de {len(partidas_pendientes)}"
|
||||||
|
)
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logging.error(
|
||||||
|
f"Error encolando partidas para pedimento {pedimento.pedimento}: {e}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def documentos_con_errores(organizacion_id):
|
def documentos_con_errores(organizacion_id):
|
||||||
@@ -489,6 +674,93 @@ def ejecutar_todos_por_organizacion(organizacion_id):
|
|||||||
procesar_pedimentos_completos.delay(organizacion_id)
|
procesar_pedimentos_completos.delay(organizacion_id)
|
||||||
procesar_remesas.delay(organizacion_id)
|
procesar_remesas.delay(organizacion_id)
|
||||||
|
|
||||||
|
def ejecutar_basicos_organizacion(organizacion_id):
|
||||||
|
# solo coves y e documents, si es necesario ya en un futuro se agregan los de partidas, pedimento completo y esas madres
|
||||||
|
procesar_coves.delay(organizacion_id)
|
||||||
|
procesar_acuse_coves.delay(organizacion_id)
|
||||||
|
procesar_edocs.delay(organizacion_id)
|
||||||
|
procesar_acuses.delay(organizacion_id)
|
||||||
|
# procesar_partidas.delay(organizacion_id)
|
||||||
|
# procesar_pedimentos_completos.delay(organizacion_id)
|
||||||
|
# procesar_remesas.delay(organizacion_id)
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def process_organization_batch(org_id):
|
||||||
|
"""
|
||||||
|
Procesa todos los tipos de documentos pendientes para una organización.
|
||||||
|
"""
|
||||||
|
ejecutar_basicos_organizacion(org_id)
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def process_all_organizations():
|
||||||
|
"""
|
||||||
|
Envía una tarea por organización activa a la cola org_processing.
|
||||||
|
"""
|
||||||
|
active_orgs = Organizacion.objects.filter(
|
||||||
|
is_active=True,
|
||||||
|
is_verified=True,
|
||||||
|
apply_auto_download=True,
|
||||||
|
)
|
||||||
|
for org in active_orgs:
|
||||||
|
process_organization_batch.apply_async(
|
||||||
|
args=[str(org.id)],
|
||||||
|
queue='org_processing'
|
||||||
|
)
|
||||||
|
return f"Dispatched {active_orgs.count()} organizations"
|
||||||
|
|
||||||
|
@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"
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ from django.urls import reverse
|
|||||||
from rest_framework.test import APITestCase, APIClient
|
from rest_framework.test import APITestCase, APIClient
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
from unittest.mock import patch
|
||||||
|
from io import BytesIO
|
||||||
|
import zipfile
|
||||||
from api.organization.models import Organizacion
|
from api.organization.models import Organizacion
|
||||||
|
from api.licence.models import Licencia
|
||||||
from .models import Pedimento, TipoOperacion, ProcesamientoPedimento, EDocument
|
from .models import Pedimento, TipoOperacion, ProcesamientoPedimento, EDocument
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
@@ -75,3 +80,767 @@ class CustomsViewsTests(APITestCase):
|
|||||||
self.client.force_authenticate(user=self.admin)
|
self.client.force_authenticate(user=self.admin)
|
||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests de integración para bulk-create (ViewSetPedimento.bulk_create)
|
||||||
|
# Verifica que al re-cargar un pedimento existente sus documentos se actualicen
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class BulkCreateDocumentReplaceTests(APITestCase):
|
||||||
|
"""Verifica que bulk-create actualiza los documentos de pedimentos existentes
|
||||||
|
en vez de ignorarlos, y que no quedan archivos residuales en el storage."""
|
||||||
|
|
||||||
|
PEDIMENTO_APP = "24-01-3420-1234567"
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.licencia = Licencia.objects.create(nombre="Lic100GB", almacenamiento=100)
|
||||||
|
self.org = Organizacion.objects.create(
|
||||||
|
nombre="OrgBulkCreate",
|
||||||
|
licencia=self.licencia,
|
||||||
|
is_active=True,
|
||||||
|
is_verified=True,
|
||||||
|
)
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username="bulkcreateuser", password="pass", organizacion=self.org
|
||||||
|
)
|
||||||
|
self.pedimento = Pedimento.objects.create(
|
||||||
|
organizacion=self.org,
|
||||||
|
pedimento="1234567",
|
||||||
|
pedimento_app=self.PEDIMENTO_APP,
|
||||||
|
)
|
||||||
|
from api.record.models import DocumentType, Fuente
|
||||||
|
self.doc_type = DocumentType.objects.get_or_create(nombre="Pedimento")[0]
|
||||||
|
# bulk_create usa fuente_id=4 hardcodeado; debe existir en la DB de test
|
||||||
|
Fuente.objects.get_or_create(id=4, defaults={"nombre": "Bulk Create"})
|
||||||
|
self.url = reverse("Pedimento-bulk-create")
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
|
def _make_zip(self, files_dict):
|
||||||
|
"""Crea un ZIP en memoria. files_dict = {nombre_archivo: contenido_bytes}"""
|
||||||
|
buf = BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, "w") as zf:
|
||||||
|
for name, content in files_dict.items():
|
||||||
|
zf.writestr(name, content)
|
||||||
|
buf.seek(0)
|
||||||
|
return SimpleUploadedFile(
|
||||||
|
f"{self.PEDIMENTO_APP}.zip", buf.read(), content_type="application/zip"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _post_zip(self, files_dict):
|
||||||
|
return self.client.post(
|
||||||
|
self.url,
|
||||||
|
{"contribuyente": "XAXX010101000", "archivos": [self._make_zip(files_dict)]},
|
||||||
|
format="multipart",
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("api.customs.views.storage_service")
|
||||||
|
def test_existing_pedimento_not_duplicated(self, mock_st):
|
||||||
|
"""Re-subir un pedimento existente NO debe crear un segundo Pedimento."""
|
||||||
|
mock_st.save_document_from_path.return_value = "org_1/documents/ped/informe_a1b2c3d4.pdf"
|
||||||
|
|
||||||
|
self._post_zip({"informe.pdf": b"contenido"})
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
Pedimento.objects.filter(
|
||||||
|
organizacion=self.org, pedimento_app=self.PEDIMENTO_APP
|
||||||
|
).count(),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("api.customs.views.storage_service")
|
||||||
|
def test_existing_pedimento_document_replaced_not_duplicated(self, mock_st):
|
||||||
|
"""Documento existente con el mismo nombre base se reemplaza, no se duplica."""
|
||||||
|
from api.record.models import Document
|
||||||
|
|
||||||
|
old_path = f"org_1/documents/{self.PEDIMENTO_APP}/informe_a1b2c3d4.pdf"
|
||||||
|
old_doc = Document.objects.create(
|
||||||
|
organizacion=self.org,
|
||||||
|
pedimento=self.pedimento,
|
||||||
|
document_type=self.doc_type,
|
||||||
|
archivo=old_path,
|
||||||
|
size=500,
|
||||||
|
extension="pdf",
|
||||||
|
)
|
||||||
|
new_path = f"org_1/documents/{self.PEDIMENTO_APP}/informe_b5c6d7e8.pdf"
|
||||||
|
mock_st.save_document_from_path.return_value = new_path
|
||||||
|
mock_st.delete_file.return_value = True
|
||||||
|
|
||||||
|
self._post_zip({"informe.pdf": b"contenido actualizado"})
|
||||||
|
|
||||||
|
docs = Document.objects.filter(pedimento=self.pedimento)
|
||||||
|
# Sin duplicados
|
||||||
|
self.assertEqual(docs.count(), 1)
|
||||||
|
# Mismo registro
|
||||||
|
self.assertEqual(docs.first().id, old_doc.id)
|
||||||
|
# Archivo actualizado
|
||||||
|
old_doc.refresh_from_db()
|
||||||
|
self.assertEqual(old_doc.archivo.name, new_path)
|
||||||
|
|
||||||
|
@patch("api.customs.views.storage_service")
|
||||||
|
def test_existing_pedimento_stale_file_deleted_from_storage(self, mock_st):
|
||||||
|
"""Al reemplazar un documento, el archivo viejo debe eliminarse del storage."""
|
||||||
|
from api.record.models import Document
|
||||||
|
|
||||||
|
old_path = f"org_1/documents/{self.PEDIMENTO_APP}/informe_a1b2c3d4.pdf"
|
||||||
|
Document.objects.create(
|
||||||
|
organizacion=self.org,
|
||||||
|
pedimento=self.pedimento,
|
||||||
|
document_type=self.doc_type,
|
||||||
|
archivo=old_path,
|
||||||
|
size=500,
|
||||||
|
extension="pdf",
|
||||||
|
)
|
||||||
|
mock_st.save_document_from_path.return_value = f"org_1/documents/{self.PEDIMENTO_APP}/informe_b5c6d7e8.pdf"
|
||||||
|
mock_st.delete_file.return_value = True
|
||||||
|
|
||||||
|
self._post_zip({"informe.pdf": b"contenido"})
|
||||||
|
|
||||||
|
# delete_file debe haberse llamado con la ruta del archivo viejo
|
||||||
|
mock_st.delete_file.assert_called()
|
||||||
|
called_arg = str(mock_st.delete_file.call_args[0][0])
|
||||||
|
self.assertIn("informe_a1b2c3d4", called_arg)
|
||||||
|
|
||||||
|
@patch("api.customs.views.storage_service")
|
||||||
|
def test_existing_pedimento_new_file_added(self, mock_st):
|
||||||
|
"""Archivo nuevo en el ZIP se añade al pedimento existente."""
|
||||||
|
from api.record.models import Document
|
||||||
|
|
||||||
|
mock_st.save_document_from_path.return_value = "org_1/documents/ped/nuevo_b5c6d7e8.pdf"
|
||||||
|
|
||||||
|
self._post_zip({"nuevo_documento.pdf": b"contenido nuevo"})
|
||||||
|
|
||||||
|
self.assertGreaterEqual(
|
||||||
|
Document.objects.filter(pedimento=self.pedimento).count(), 1
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("api.customs.views.storage_service")
|
||||||
|
def test_already_existing_count_in_response(self, mock_st):
|
||||||
|
"""La respuesta debe indicar que el pedimento ya existía (already_existing_count >= 1)."""
|
||||||
|
mock_st.save_document_from_path.return_value = "org_1/documents/ped/f_a1b2c3d4.pdf"
|
||||||
|
|
||||||
|
response = self._post_zip({"archivo.pdf": b"contenido"})
|
||||||
|
|
||||||
|
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_207_MULTI_STATUS, status.HTTP_201_CREATED])
|
||||||
|
data = response.json()
|
||||||
|
self.assertGreaterEqual(data.get("already_existing_count", 0), 1)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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, SimpleTestCase
|
||||||
|
from core.partida_docs import es_doc_de_partida, patron_regex_partida
|
||||||
|
from api.customs.serializers import PartidaSerializer, CoveSerializer, EDocumentSerializer
|
||||||
|
from api.customs.models import Partida
|
||||||
|
from api.record.models import Document, DocumentType
|
||||||
|
|
||||||
|
|
||||||
|
XML_RESPUESTA_VALIDA = (
|
||||||
|
"<?xml version='1.0' encoding='UTF-8'?>"
|
||||||
|
'<S:Envelope xmlns:S="http://schemas.xmlsoap.org/soap/envelope/"><S:Body>'
|
||||||
|
'<ns9:consultarPartidaRespuesta xmlns:ns9="http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/consultarpartida">'
|
||||||
|
"<tieneError>false</tieneError><ns9:partida/></ns9:consultarPartidaRespuesta>"
|
||||||
|
"</S:Body></S:Envelope>"
|
||||||
|
)
|
||||||
|
|
||||||
|
XML_ERROR_VUCEM = (
|
||||||
|
"<?xml version='1.0' encoding='UTF-8'?>"
|
||||||
|
'<S:Envelope xmlns:S="http://schemas.xmlsoap.org/soap/envelope/"><S:Body>'
|
||||||
|
'<ns9:consultarPartidaRespuesta xmlns:ns9="http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/consultarpartida">'
|
||||||
|
"<tieneError>true</tieneError></ns9:consultarPartidaRespuesta>"
|
||||||
|
"</S:Body></S:Envelope>"
|
||||||
|
)
|
||||||
|
|
||||||
|
XML_ECO_REQUEST = (
|
||||||
|
"<?xml version='1.0' encoding='UTF-8'?>"
|
||||||
|
'<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"'
|
||||||
|
' xmlns:con="http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/consultarpartida"><soapenv:Body>'
|
||||||
|
"<con:consultarPartidaPeticion><con:peticion/></con:consultarPartidaPeticion>"
|
||||||
|
"</soapenv:Body></soapenv:Envelope>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeMinioObject:
|
||||||
|
"""Simula el objeto retornado por minio get_object."""
|
||||||
|
|
||||||
|
def __init__(self, content):
|
||||||
|
self._content = content
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return self._content
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def release_conn(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FixPartidasErrorCommandTests(TestCase):
|
||||||
|
PED_APP = "24-01-3420-1234567"
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
from api.customs.models import Partida
|
||||||
|
from api.record.models import DocumentType
|
||||||
|
|
||||||
|
self.licencia = Licencia.objects.create(nombre="LicFixPartidas", almacenamiento=100)
|
||||||
|
self.org = Organizacion.objects.create(
|
||||||
|
nombre="OrgFixPartidas", licencia=self.licencia, is_active=True, is_verified=True
|
||||||
|
)
|
||||||
|
# Pedimento VÁLIDO (no malformado): el comando ya no se limita a malformados
|
||||||
|
self.pedimento = Pedimento.objects.create(
|
||||||
|
organizacion=self.org,
|
||||||
|
pedimento="1234567",
|
||||||
|
pedimento_app=self.PED_APP,
|
||||||
|
aduana="034",
|
||||||
|
patente="3420",
|
||||||
|
numero_operacion="12345678",
|
||||||
|
)
|
||||||
|
self.partida = Partida.objects.create(
|
||||||
|
pedimento=self.pedimento,
|
||||||
|
organizacion=self.org,
|
||||||
|
numero_partida=1,
|
||||||
|
descargado=True,
|
||||||
|
)
|
||||||
|
self.type_resp = DocumentType.objects.get_or_create(id=1, defaults={"nombre": "XML"})[0]
|
||||||
|
self.type_req = DocumentType.objects.get_or_create(id=17, defaults={"nombre": "PT Request"})[0]
|
||||||
|
self.type_err = DocumentType.objects.get_or_create(id=18, defaults={"nombre": "PT Error"})[0]
|
||||||
|
|
||||||
|
# Storage simulado: dict path -> bytes
|
||||||
|
self.storage = {}
|
||||||
|
patcher = patch("api.customs.management.commands.fix_partidas_error.minio_client")
|
||||||
|
self.minio = patcher.start()
|
||||||
|
self.addCleanup(patcher.stop)
|
||||||
|
self.minio._bucket_name = "test-bucket"
|
||||||
|
self.minio.file_exists.side_effect = lambda name: name in self.storage
|
||||||
|
self.minio._client.get_object.side_effect = (
|
||||||
|
lambda bucket, name: _FakeMinioObject(self.storage[name])
|
||||||
|
)
|
||||||
|
self.minio.upload_file.side_effect = (
|
||||||
|
lambda name, file_data=None, content_type=None: self.storage.__setitem__(
|
||||||
|
name, file_data.read()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.minio.delete_file.side_effect = lambda name: self.storage.pop(name, None)
|
||||||
|
|
||||||
|
def _doc(self, filename, doc_type, content=None):
|
||||||
|
from api.record.models import Document
|
||||||
|
|
||||||
|
path = f"org/{self.PED_APP}/{filename}"
|
||||||
|
doc = Document.objects.create(
|
||||||
|
organizacion=self.org,
|
||||||
|
pedimento=self.pedimento,
|
||||||
|
document_type=doc_type,
|
||||||
|
archivo=path,
|
||||||
|
size=100,
|
||||||
|
extension="xml",
|
||||||
|
)
|
||||||
|
if content is not None:
|
||||||
|
self.storage[path] = content.encode("utf-8")
|
||||||
|
return doc
|
||||||
|
|
||||||
|
def _run(self, **kwargs):
|
||||||
|
out = StringIO()
|
||||||
|
call_command("fix_partidas_error", stdout=out, stderr=StringIO(), **kwargs)
|
||||||
|
return out.getvalue()
|
||||||
|
|
||||||
|
def test_partida_solo_request_se_marca_no_descargada(self):
|
||||||
|
"""El caso reportado: descargado=True pero solo existe el XML del REQUEST."""
|
||||||
|
self._doc(f"vu_PT_{self.PED_APP}_1_REQUEST.xml", self.type_req, XML_ECO_REQUEST)
|
||||||
|
|
||||||
|
self._run(pedimento=str(self.pedimento.id))
|
||||||
|
|
||||||
|
self.partida.refresh_from_db()
|
||||||
|
self.assertFalse(self.partida.descargado)
|
||||||
|
|
||||||
|
def test_partida_sin_documentos_se_marca_no_descargada(self):
|
||||||
|
"""descargado=True sin ningún documento tampoco es una descarga real."""
|
||||||
|
self._run(pedimento=str(self.pedimento.id))
|
||||||
|
|
||||||
|
self.partida.refresh_from_db()
|
||||||
|
self.assertFalse(self.partida.descargado)
|
||||||
|
|
||||||
|
def test_partida_con_respuesta_valida_permanece_descargada(self):
|
||||||
|
"""Con consultarPartidaRespuesta sin error la partida no se toca."""
|
||||||
|
self._doc(f"vu_PT_{self.PED_APP}_1_REQUEST.xml", self.type_req, XML_ECO_REQUEST)
|
||||||
|
self._doc(f"vu_PT_{self.PED_APP}_1.xml", self.type_resp, XML_RESPUESTA_VALIDA)
|
||||||
|
|
||||||
|
self._run(pedimento=str(self.pedimento.id))
|
||||||
|
|
||||||
|
self.partida.refresh_from_db()
|
||||||
|
self.assertTrue(self.partida.descargado)
|
||||||
|
|
||||||
|
def test_doc_con_error_vucem_se_renombra_y_marca_no_descargada(self):
|
||||||
|
"""tieneError=true: doc → type 18 con sufijo _ERROR y partida → False."""
|
||||||
|
doc = self._doc(f"vu_PT_{self.PED_APP}_1.xml", self.type_resp, XML_ERROR_VUCEM)
|
||||||
|
old_path = doc.archivo.name
|
||||||
|
|
||||||
|
self._run(pedimento=str(self.pedimento.id))
|
||||||
|
|
||||||
|
self.partida.refresh_from_db()
|
||||||
|
doc.refresh_from_db()
|
||||||
|
self.assertFalse(self.partida.descargado)
|
||||||
|
self.assertEqual(doc.document_type_id, 18)
|
||||||
|
self.assertTrue(doc.archivo.name.endswith(f"vu_PT_{self.PED_APP}_1_ERROR.xml"))
|
||||||
|
self.assertTrue(doc.vu)
|
||||||
|
self.assertNotIn(old_path, self.storage)
|
||||||
|
self.assertIn(doc.archivo.name, self.storage)
|
||||||
|
|
||||||
|
def test_eco_de_request_guardado_como_respuesta_se_reclasifica(self):
|
||||||
|
"""Un eco de consultarPartidaPeticion guardado como respuesta se
|
||||||
|
reclasifica a type 17 sin chocar con el REQUEST real existente."""
|
||||||
|
self._doc(f"vu_PT_{self.PED_APP}_1_REQUEST.xml", self.type_req, XML_ECO_REQUEST)
|
||||||
|
doc = self._doc(f"vu_PT_{self.PED_APP}_1.xml", self.type_resp, XML_ECO_REQUEST)
|
||||||
|
|
||||||
|
self._run(pedimento=str(self.pedimento.id))
|
||||||
|
|
||||||
|
self.partida.refresh_from_db()
|
||||||
|
doc.refresh_from_db()
|
||||||
|
self.assertFalse(self.partida.descargado)
|
||||||
|
self.assertEqual(doc.document_type_id, 17)
|
||||||
|
# El nombre sin índice ya lo usa el REQUEST real → debe ir con _1
|
||||||
|
self.assertTrue(doc.archivo.name.endswith(f"vu_PT_{self.PED_APP}_1_REQUEST_1.xml"))
|
||||||
|
|
||||||
|
def test_doc_ausente_sin_canario_no_cambia_partida(self):
|
||||||
|
"""Archivo ausente y NINGÚN archivo del pedimento en storage: posible
|
||||||
|
storage equivocado (p. ej. dev) → sin cambios."""
|
||||||
|
self._doc(f"vu_PT_{self.PED_APP}_1.xml", self.type_resp, content=None)
|
||||||
|
|
||||||
|
self._run(pedimento=str(self.pedimento.id))
|
||||||
|
|
||||||
|
self.partida.refresh_from_db()
|
||||||
|
self.assertTrue(self.partida.descargado)
|
||||||
|
|
||||||
|
def test_registro_fantasma_con_storage_real_se_marca_no_descargada(self):
|
||||||
|
"""Document type 1 en BD sin archivo en storage, pero el REQUEST sí
|
||||||
|
existe físicamente (canario): el storage es el correcto, el registro es
|
||||||
|
fantasma → la partida no tiene XML de partida → descargado=False."""
|
||||||
|
self._doc(f"vu_PT_{self.PED_APP}_1_REQUEST.xml", self.type_req, XML_ECO_REQUEST)
|
||||||
|
fantasma = self._doc(f"vu_PT_{self.PED_APP}_1.xml", self.type_resp, content=None)
|
||||||
|
|
||||||
|
self._run(pedimento=str(self.pedimento.id))
|
||||||
|
|
||||||
|
self.partida.refresh_from_db()
|
||||||
|
fantasma.refresh_from_db()
|
||||||
|
self.assertFalse(self.partida.descargado)
|
||||||
|
# El registro fantasma se reporta pero no se modifica ni se borra
|
||||||
|
self.assertEqual(fantasma.document_type_id, 1)
|
||||||
|
|
||||||
|
def test_storage_inaccesible_no_cambia_partida(self):
|
||||||
|
"""Excepción al consultar storage (conexión caída): sin cambios."""
|
||||||
|
self._doc(f"vu_PT_{self.PED_APP}_1.xml", self.type_resp, XML_ERROR_VUCEM)
|
||||||
|
self.minio.file_exists.side_effect = Exception("connection refused")
|
||||||
|
|
||||||
|
self._run(pedimento=str(self.pedimento.id))
|
||||||
|
|
||||||
|
self.partida.refresh_from_db()
|
||||||
|
self.assertTrue(self.partida.descargado)
|
||||||
|
|
||||||
|
def test_naming_legacy_valida_partida(self):
|
||||||
|
"""Documentos con nomenclatura legacy (partida al final) también validan."""
|
||||||
|
self._doc("vu_PT_010Imp_034_3420_1234567_1.xml", self.type_resp, XML_RESPUESTA_VALIDA)
|
||||||
|
|
||||||
|
self._run(pedimento=str(self.pedimento.id))
|
||||||
|
|
||||||
|
self.partida.refresh_from_db()
|
||||||
|
self.assertTrue(self.partida.descargado)
|
||||||
|
|
||||||
|
def test_dry_run_no_modifica(self):
|
||||||
|
"""--dry-run reporta pero no toca BD ni storage."""
|
||||||
|
doc = self._doc(f"vu_PT_{self.PED_APP}_1.xml", self.type_resp, XML_ERROR_VUCEM)
|
||||||
|
|
||||||
|
self._run(pedimento=str(self.pedimento.id), dry_run=True)
|
||||||
|
|
||||||
|
self.partida.refresh_from_db()
|
||||||
|
doc.refresh_from_db()
|
||||||
|
self.assertTrue(self.partida.descargado)
|
||||||
|
self.assertEqual(doc.document_type_id, 1)
|
||||||
|
self.assertIn(doc.archivo.name, self.storage)
|
||||||
|
|
||||||
|
def test_universo_general_incluye_pedimentos_validos(self):
|
||||||
|
"""Sin --pedimento ni --solo-malformados también procesa pedimentos bien formados."""
|
||||||
|
self._doc(f"vu_PT_{self.PED_APP}_1_REQUEST.xml", self.type_req, XML_ECO_REQUEST)
|
||||||
|
|
||||||
|
self._run()
|
||||||
|
|
||||||
|
self.partida.refresh_from_db()
|
||||||
|
self.assertFalse(self.partida.descargado)
|
||||||
|
|
||||||
|
def test_solo_malformados_excluye_pedimentos_validos(self):
|
||||||
|
"""Con --solo-malformados un pedimento bien formado no se procesa."""
|
||||||
|
self._doc(f"vu_PT_{self.PED_APP}_1_REQUEST.xml", self.type_req, XML_ECO_REQUEST)
|
||||||
|
|
||||||
|
self._run(solo_malformados=True)
|
||||||
|
|
||||||
|
self.partida.refresh_from_db()
|
||||||
|
self.assertTrue(self.partida.descargado)
|
||||||
|
|
||||||
|
def test_no_confunde_partida_1_con_11(self):
|
||||||
|
"""La asignación por nombre no debe mezclar partida 1 con partida 11."""
|
||||||
|
from api.customs.management.commands.fix_partidas_error import Command
|
||||||
|
|
||||||
|
docs = [
|
||||||
|
SimpleNamespace(id=1, document_type_id=1, archivo=SimpleNamespace(name=f"org/x/vu_PT_{self.PED_APP}_1.xml")),
|
||||||
|
SimpleNamespace(id=2, document_type_id=17, archivo=SimpleNamespace(name=f"org/x/vu_PT_{self.PED_APP}_1_REQUEST.xml")),
|
||||||
|
SimpleNamespace(id=3, document_type_id=1, archivo=SimpleNamespace(name=f"org/x/vu_PT_{self.PED_APP}_11.xml")),
|
||||||
|
SimpleNamespace(id=4, document_type_id=17, archivo=SimpleNamespace(name=f"org/x/vu_PT_{self.PED_APP}_11_REQUEST.xml")),
|
||||||
|
]
|
||||||
|
cmd = Command()
|
||||||
|
|
||||||
|
ids_p1 = {d.id for d in cmd._docs_de_partida(docs, self.PED_APP, 1)}
|
||||||
|
ids_p11 = {d.id for d in cmd._docs_de_partida(docs, self.PED_APP, 11)}
|
||||||
|
|
||||||
|
self.assertEqual(ids_p1, {1, 2})
|
||||||
|
self.assertEqual(ids_p11, {3, 4})
|
||||||
|
|
||||||
|
|
||||||
|
class PartidaDocsHelperTests(SimpleTestCase):
|
||||||
|
"""Matching documento→partida (core.partida_docs), sin BD."""
|
||||||
|
|
||||||
|
APP = "24-01-3420-1234567"
|
||||||
|
|
||||||
|
def test_es_doc_de_partida_cubre_los_tres_formatos(self):
|
||||||
|
for nombre in (
|
||||||
|
f"documents/vu_PT_{self.APP}_1", # #1 sin extensión
|
||||||
|
f"documents/vu_PT_{self.APP}_1.xml", # #2
|
||||||
|
f"documents/vu_PT_{self.APP}_1_a1b2c3.xml", # #3 sufijo hex del storage
|
||||||
|
f"documents/vu_PT_{self.APP}_1_REQUEST.xml", # REQUEST (coincide por nombre)
|
||||||
|
):
|
||||||
|
self.assertTrue(es_doc_de_partida(nombre, self.APP, 1), nombre)
|
||||||
|
|
||||||
|
def test_es_doc_de_partida_no_confunde_1_con_11_ni_100(self):
|
||||||
|
for n in (11, 12, 100):
|
||||||
|
self.assertFalse(es_doc_de_partida(f"vu_PT_{self.APP}_{n}.xml", self.APP, 1))
|
||||||
|
self.assertFalse(es_doc_de_partida(f"vu_PT_{self.APP}_{n}_x.xml", self.APP, 1))
|
||||||
|
# a la inversa: la 11 sí coincide con la 11, no con la 1
|
||||||
|
self.assertTrue(es_doc_de_partida(f"vu_PT_{self.APP}_11.xml", self.APP, 11))
|
||||||
|
self.assertFalse(es_doc_de_partida(f"vu_PT_{self.APP}_1.xml", self.APP, 11))
|
||||||
|
|
||||||
|
def test_es_doc_de_partida_case_insensitive_y_con_ruta(self):
|
||||||
|
self.assertTrue(es_doc_de_partida(f"ORG/X/VU_PT_{self.APP}_1.XML", self.APP, 1))
|
||||||
|
|
||||||
|
def test_es_doc_de_partida_vacio_o_none(self):
|
||||||
|
self.assertFalse(es_doc_de_partida("", self.APP, 1))
|
||||||
|
self.assertFalse(es_doc_de_partida(None, self.APP, 1))
|
||||||
|
|
||||||
|
def test_es_doc_de_partida_legacy_segun_flag(self):
|
||||||
|
legacy = "org/x/vu_PT_010Imp_034_3420_1234567_1.xml" # número de partida al final
|
||||||
|
self.assertTrue(es_doc_de_partida(legacy, self.APP, 1, incluir_legacy=True))
|
||||||
|
self.assertFalse(es_doc_de_partida(legacy, self.APP, 1, incluir_legacy=False))
|
||||||
|
# el formato legacy tampoco confunde la 1 con la 11
|
||||||
|
legacy11 = "org/x/vu_PT_010Imp_034_3420_1234567_11.xml"
|
||||||
|
self.assertFalse(es_doc_de_partida(legacy11, self.APP, 1, incluir_legacy=True))
|
||||||
|
|
||||||
|
def test_patron_regex_partida_semantica(self):
|
||||||
|
import re
|
||||||
|
rx = re.compile(patron_regex_partida(self.APP, 1), re.IGNORECASE)
|
||||||
|
self.assertTrue(rx.search(f"documents/vu_PT_{self.APP}_1"))
|
||||||
|
self.assertTrue(rx.search(f"documents/vu_PT_{self.APP}_1.xml"))
|
||||||
|
self.assertTrue(rx.search(f"documents/vu_PT_{self.APP}_1_a1b2.xml"))
|
||||||
|
self.assertFalse(rx.search(f"documents/vu_PT_{self.APP}_11.xml"))
|
||||||
|
self.assertFalse(rx.search(f"documents/vu_PT_{self.APP}_100.xml"))
|
||||||
|
# pedimento_app se trata como literal: otro pedimento no coincide
|
||||||
|
self.assertFalse(rx.search("documents/vu_PT_99-99-9999-9999999_1.xml"))
|
||||||
|
# legacy solo cuando se pide
|
||||||
|
rxl = re.compile(patron_regex_partida(self.APP, 1, incluir_legacy=True), re.IGNORECASE)
|
||||||
|
self.assertTrue(rxl.search("vu_PT_010Imp_034_3420_1234567_1.xml"))
|
||||||
|
|
||||||
|
|
||||||
|
class PartidaDocumentosSerializerTests(TestCase):
|
||||||
|
"""get_documentos (PartidaSerializer) y el prefetch de PartidaViewSet asignan
|
||||||
|
los documentos correctos a cada partida por nombre de archivo."""
|
||||||
|
|
||||||
|
APP = "24-01-3420-1234567"
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
from api.organization.models import Organizacion
|
||||||
|
from api.licence.models import Licencia
|
||||||
|
from .models import Pedimento
|
||||||
|
|
||||||
|
self.licencia = Licencia.objects.create(nombre="LicPartDocs", almacenamiento=100)
|
||||||
|
self.org = Organizacion.objects.create(
|
||||||
|
nombre="OrgPartDocs", licencia=self.licencia, is_active=True, is_verified=True
|
||||||
|
)
|
||||||
|
self.pedimento = Pedimento.objects.create(
|
||||||
|
organizacion=self.org, pedimento="1234567", pedimento_app=self.APP,
|
||||||
|
aduana="034", patente="3420", numero_operacion="12345678",
|
||||||
|
)
|
||||||
|
self.p1 = Partida.objects.create(
|
||||||
|
pedimento=self.pedimento, organizacion=self.org, numero_partida=1, descargado=True
|
||||||
|
)
|
||||||
|
self.p11 = Partida.objects.create(
|
||||||
|
pedimento=self.pedimento, organizacion=self.org, numero_partida=11, 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]
|
||||||
|
|
||||||
|
def _doc(self, filename, doc_type=None):
|
||||||
|
return Document.objects.create(
|
||||||
|
organizacion=self.org, pedimento=self.pedimento,
|
||||||
|
document_type=doc_type or self.type_resp,
|
||||||
|
archivo=f"documents/{filename}", size=100, extension="xml",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _blob(self, data):
|
||||||
|
return " ".join(d["archivo"] for d in data["documentos"])
|
||||||
|
|
||||||
|
def test_get_documentos_tres_formatos_sin_confundir(self):
|
||||||
|
self._doc(f"vu_PT_{self.APP}_1") # #1 sin extensión
|
||||||
|
self._doc(f"vu_PT_{self.APP}_1.xml") # #2
|
||||||
|
self._doc(f"vu_PT_{self.APP}_1_a1b2c3.xml") # #3 hex
|
||||||
|
self._doc(f"vu_PT_{self.APP}_1_REQUEST.xml", self.type_req) # tipo 17: no debe salir
|
||||||
|
self._doc(f"vu_PT_{self.APP}_11.xml") # partida 11
|
||||||
|
self._doc(f"vu_PT_{self.APP}_11_a1b2c3.xml") # partida 11
|
||||||
|
|
||||||
|
data = PartidaSerializer(self.p1).data # sin contexto -> camino fallback
|
||||||
|
blob = self._blob(data)
|
||||||
|
# los 3 documentos tipo-1 de la partida 1; el REQUEST (17) excluido
|
||||||
|
self.assertEqual(len(data["documentos"]), 3)
|
||||||
|
self.assertIn(f"vu_PT_{self.APP}_1.xml", blob)
|
||||||
|
self.assertIn(f"vu_PT_{self.APP}_1_a1b2c3.xml", blob)
|
||||||
|
self.assertNotIn("_11", blob) # no arrastra la partida 11
|
||||||
|
self.assertNotIn("REQUEST", blob) # no incluye el REQUEST tipo 17
|
||||||
|
|
||||||
|
def test_get_documentos_incluye_legacy(self):
|
||||||
|
self._doc("vu_PT_010Imp_034_3420_1234567_1.xml") # legacy tipo 1, número al final
|
||||||
|
data = PartidaSerializer(self.p1).data
|
||||||
|
self.assertEqual(len(data["documentos"]), 1)
|
||||||
|
|
||||||
|
def test_get_documentos_via_fk(self):
|
||||||
|
# save() liga la FK al crear; get_documentos lee por la FK real.
|
||||||
|
d1 = self._doc(f"vu_PT_{self.APP}_1.xml")
|
||||||
|
self._doc(f"vu_PT_{self.APP}_11.xml")
|
||||||
|
d1.refresh_from_db()
|
||||||
|
self.assertEqual(d1.partida_id, self.p1.id) # FK ligada en save()
|
||||||
|
data = PartidaSerializer(self.p1).data
|
||||||
|
self.assertEqual(len(data["documentos"]), 1)
|
||||||
|
self.assertIn(f"vu_PT_{self.APP}_1.xml", self._blob(data))
|
||||||
|
self.assertNotIn("_11", self._blob(data))
|
||||||
|
|
||||||
|
def test_patron_regex_partida_en_bd(self):
|
||||||
|
d1 = self._doc(f"vu_PT_{self.APP}_1")
|
||||||
|
d2 = self._doc(f"vu_PT_{self.APP}_1.xml")
|
||||||
|
d3 = self._doc(f"vu_PT_{self.APP}_1_a1b2c3.xml")
|
||||||
|
d_otro = self._doc(f"vu_PT_{self.APP}_11.xml")
|
||||||
|
ids = set(
|
||||||
|
Document.objects.filter(
|
||||||
|
pedimento=self.pedimento,
|
||||||
|
archivo__iregex=patron_regex_partida(self.APP, 1),
|
||||||
|
).values_list("id", flat=True)
|
||||||
|
)
|
||||||
|
self.assertEqual(ids, {d1.id, d2.id, d3.id})
|
||||||
|
self.assertNotIn(d_otro.id, ids)
|
||||||
|
|
||||||
|
def test_prefetch_documentos_vu_evita_n_plus_1(self):
|
||||||
|
from django.db.models import Prefetch
|
||||||
|
self._doc(f"vu_PT_{self.APP}_1.xml")
|
||||||
|
self._doc(f"vu_PT_{self.APP}_11.xml")
|
||||||
|
prefetch = Prefetch(
|
||||||
|
'documents',
|
||||||
|
queryset=Document.objects.filter(document_type_id=1).select_related('pedimento'),
|
||||||
|
to_attr='documentos_vu',
|
||||||
|
)
|
||||||
|
partidas = list(
|
||||||
|
Partida.objects.filter(pedimento=self.pedimento)
|
||||||
|
.order_by('numero_partida').prefetch_related(prefetch)
|
||||||
|
)
|
||||||
|
# Serializar la lista no dispara consultas extra: todo viene del prefetch.
|
||||||
|
with self.assertNumQueries(0):
|
||||||
|
data = PartidaSerializer(partidas, many=True).data
|
||||||
|
por_num = {d['numero_partida']: d['documentos'] for d in data}
|
||||||
|
self.assertEqual(len(por_num[1]), 1)
|
||||||
|
self.assertEqual(len(por_num[11]), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentLinksHelperTests(SimpleTestCase):
|
||||||
|
"""Resolver tipo→sección y matcher por frontera (core.document_links), sin BD."""
|
||||||
|
|
||||||
|
# pedimento_app con guiones bajos (caso real): no se puede extraer la llave
|
||||||
|
# partiendo por '_'; por eso se itera la entidad con su número exacto.
|
||||||
|
APP = "0101_230_1703_3004804"
|
||||||
|
|
||||||
|
def test_seccion_de_tipo(self):
|
||||||
|
from core.document_links import seccion_de_tipo
|
||||||
|
self.assertEqual(seccion_de_tipo(1), 'partida')
|
||||||
|
self.assertEqual(seccion_de_tipo(8), 'cove')
|
||||||
|
self.assertEqual(seccion_de_tipo(7), 'cove') # acuse cove
|
||||||
|
self.assertEqual(seccion_de_tipo(5), 'edocument')
|
||||||
|
self.assertEqual(seccion_de_tipo(4), 'edocument') # acuse edoc
|
||||||
|
self.assertIsNone(seccion_de_tipo(2)) # PC nativo
|
||||||
|
self.assertIsNone(seccion_de_tipo(3)) # remesa nativo
|
||||||
|
self.assertIsNone(seccion_de_tipo(None))
|
||||||
|
|
||||||
|
def test_coincide_cove(self):
|
||||||
|
from core.document_links import coincide
|
||||||
|
self.assertTrue(coincide(f"documents/vu_COVE_{self.APP}_654001.xml", 'cove', self.APP, "654001"))
|
||||||
|
self.assertTrue(coincide(f"documents/vu_AC_COVE_{self.APP}_654001.pdf", 'cove', self.APP, "654001"))
|
||||||
|
self.assertTrue(coincide(f"documents/vu_COVE_{self.APP}_654001_REQUEST.xml", 'cove', self.APP, "654001"))
|
||||||
|
# colisión de prefijo: 654001 no debe matchear 6540012
|
||||||
|
self.assertFalse(coincide(f"documents/vu_COVE_{self.APP}_6540012.xml", 'cove', self.APP, "654001"))
|
||||||
|
|
||||||
|
def test_coincide_edocument(self):
|
||||||
|
from core.document_links import coincide
|
||||||
|
self.assertTrue(coincide(f"documents/vu_ED_{self.APP}_EDOC001.pdf", 'edocument', self.APP, "EDOC001"))
|
||||||
|
self.assertTrue(coincide(f"documents/vu_AC_{self.APP}_EDOC001.pdf", 'edocument', self.APP, "EDOC001"))
|
||||||
|
self.assertFalse(coincide(f"documents/vu_ED_{self.APP}_EDOC0011.pdf", 'edocument', self.APP, "EDOC001"))
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentFKResolutionTests(TestCase):
|
||||||
|
"""save()-resolución de FK por sección, lectura cove/edoc por FK y backfill."""
|
||||||
|
|
||||||
|
APP = "0101_230_1703_3004804" # con guiones bajos
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
from api.organization.models import Organizacion
|
||||||
|
from api.licence.models import Licencia
|
||||||
|
from .models import Pedimento, Cove, EDocument
|
||||||
|
|
||||||
|
self.licencia = Licencia.objects.create(nombre="LicFK", almacenamiento=100)
|
||||||
|
self.org = Organizacion.objects.create(
|
||||||
|
nombre="OrgFK", licencia=self.licencia, is_active=True, is_verified=True
|
||||||
|
)
|
||||||
|
self.pedimento = Pedimento.objects.create(
|
||||||
|
organizacion=self.org, pedimento="1234567", pedimento_app=self.APP,
|
||||||
|
aduana="034", patente="3420", numero_operacion="12345678",
|
||||||
|
)
|
||||||
|
self.partida = Partida.objects.create(
|
||||||
|
pedimento=self.pedimento, organizacion=self.org, numero_partida=3, descargado=True
|
||||||
|
)
|
||||||
|
self.cove = Cove.objects.create(
|
||||||
|
pedimento=self.pedimento, organizacion=self.org, numero_cove="654001"
|
||||||
|
)
|
||||||
|
self.edoc = EDocument.objects.create(
|
||||||
|
pedimento=self.pedimento, organizacion=self.org, numero_edocument="EDOC001"
|
||||||
|
)
|
||||||
|
for tid, nombre in [(1, "PT"), (8, "COVE"), (7, "AC_COVE"), (5, "ED"),
|
||||||
|
(4, "AC_ED"), (2, "PC"), (19, "COVE_REQ")]:
|
||||||
|
DocumentType.objects.get_or_create(id=tid, defaults={"nombre": nombre})
|
||||||
|
|
||||||
|
def _doc(self, filename, type_id):
|
||||||
|
return Document.objects.create(
|
||||||
|
organizacion=self.org, pedimento=self.pedimento, document_type_id=type_id,
|
||||||
|
archivo=f"documents/{filename}", size=100, extension="xml",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_save_liga_fk_por_seccion(self):
|
||||||
|
d_pt = self._doc(f"vu_PT_{self.APP}_3.xml", 1)
|
||||||
|
d_cv = self._doc(f"vu_COVE_{self.APP}_654001.xml", 8)
|
||||||
|
d_accv = self._doc(f"vu_AC_COVE_{self.APP}_654001.pdf", 7)
|
||||||
|
d_ed = self._doc(f"vu_ED_{self.APP}_EDOC001.pdf", 5)
|
||||||
|
d_aced = self._doc(f"vu_AC_{self.APP}_EDOC001.pdf", 4)
|
||||||
|
d_pc = self._doc(f"vu_PC_{self.APP}.xml", 2) # nativo: sin FK
|
||||||
|
for d in (d_pt, d_cv, d_accv, d_ed, d_aced, d_pc):
|
||||||
|
d.refresh_from_db()
|
||||||
|
self.assertEqual(d_pt.partida_id, self.partida.id)
|
||||||
|
self.assertEqual(d_cv.cove_id, self.cove.id)
|
||||||
|
self.assertEqual(d_accv.cove_id, self.cove.id) # acuse cove → cove padre
|
||||||
|
self.assertEqual(d_ed.edocument_id, self.edoc.id)
|
||||||
|
self.assertEqual(d_aced.edocument_id, self.edoc.id) # acuse edoc → edoc padre
|
||||||
|
self.assertIsNone(d_pc.partida_id)
|
||||||
|
self.assertIsNone(d_pc.cove_id)
|
||||||
|
self.assertIsNone(d_pc.edocument_id)
|
||||||
|
|
||||||
|
def test_lectura_cove_edoc_por_fk(self):
|
||||||
|
self._doc(f"vu_COVE_{self.APP}_654001.xml", 8)
|
||||||
|
self._doc(f"vu_AC_COVE_{self.APP}_654001.pdf", 7)
|
||||||
|
self._doc(f"vu_COVE_{self.APP}_654001_REQUEST.xml", 19) # request: excluido en lectura
|
||||||
|
self._doc(f"vu_ED_{self.APP}_EDOC001.pdf", 5)
|
||||||
|
cove_data = CoveSerializer(self.cove).data
|
||||||
|
edoc_data = EDocumentSerializer(self.edoc).data
|
||||||
|
self.assertEqual(len(cove_data["documentos"]), 2) # cove + acuse, sin request
|
||||||
|
self.assertEqual(len(edoc_data["documentos"]), 1)
|
||||||
|
|
||||||
|
def test_backfill_liga_filas_existentes(self):
|
||||||
|
d_pt = self._doc(f"vu_PT_{self.APP}_3.xml", 1)
|
||||||
|
d_cv = self._doc(f"vu_COVE_{self.APP}_654001.xml", 8)
|
||||||
|
# Simular filas viejas sin ligar (save() las ligó; las desligamos en BD).
|
||||||
|
Document.objects.filter(id__in=[d_pt.id, d_cv.id]).update(
|
||||||
|
partida=None, cove=None, edocument=None
|
||||||
|
)
|
||||||
|
# dry-run no escribe
|
||||||
|
call_command("backfill_document_links", pedimento=str(self.pedimento.id),
|
||||||
|
dry_run=True, stdout=StringIO())
|
||||||
|
d_pt.refresh_from_db()
|
||||||
|
self.assertIsNone(d_pt.partida_id)
|
||||||
|
# ejecución real liga
|
||||||
|
call_command("backfill_document_links", pedimento=str(self.pedimento.id), stdout=StringIO())
|
||||||
|
d_pt.refresh_from_db()
|
||||||
|
d_cv.refresh_from_db()
|
||||||
|
self.assertEqual(d_pt.partida_id, self.partida.id)
|
||||||
|
self.assertEqual(d_cv.cove_id, self.cove.id)
|
||||||
|
# idempotente: re-ejecutar no rompe ni cambia
|
||||||
|
call_command("backfill_document_links", pedimento=str(self.pedimento.id), stdout=StringIO())
|
||||||
|
d_pt.refresh_from_db()
|
||||||
|
self.assertEqual(d_pt.partida_id, self.partida.id)
|
||||||
|
|
||||||
|
def _tres_copias_edoc(self):
|
||||||
|
"""3 copias del mismo edoc (type 5) con created_at d1<d2<d3 y archivos distintos."""
|
||||||
|
from django.utils import timezone
|
||||||
|
import datetime
|
||||||
|
d1 = self._doc(f"vu_ED_{self.APP}_EDOC001_aaa.pdf", 5)
|
||||||
|
d2 = self._doc(f"vu_ED_{self.APP}_EDOC001_bbb.pdf", 5)
|
||||||
|
d3 = self._doc(f"vu_ED_{self.APP}_EDOC001_ccc.pdf", 5)
|
||||||
|
base = timezone.now()
|
||||||
|
Document.objects.filter(id=d1.id).update(created_at=base - datetime.timedelta(days=2))
|
||||||
|
Document.objects.filter(id=d2.id).update(created_at=base - datetime.timedelta(days=1))
|
||||||
|
Document.objects.filter(id=d3.id).update(created_at=base)
|
||||||
|
return d1, d2, d3
|
||||||
|
|
||||||
|
def test_dedup_conserva_mas_reciente_y_es_idempotente(self):
|
||||||
|
from unittest.mock import patch
|
||||||
|
d1, d2, d3 = self._tres_copias_edoc()
|
||||||
|
self.assertEqual(Document.objects.filter(edocument=self.edoc, document_type_id=5).count(), 3)
|
||||||
|
with patch('api.customs.management.commands.dedup_documents.storage_service') as st:
|
||||||
|
st.file_exists.return_value = True
|
||||||
|
call_command('dedup_documents', pedimento=str(self.pedimento.id), stdout=StringIO())
|
||||||
|
self.assertEqual(st.delete_file.call_count, 2) # borró los 2 viejos de MinIO
|
||||||
|
restantes = list(Document.objects.filter(edocument=self.edoc, document_type_id=5))
|
||||||
|
self.assertEqual(len(restantes), 1)
|
||||||
|
self.assertEqual(restantes[0].id, d3.id) # conservó el más reciente
|
||||||
|
# idempotente: re-correr no borra nada
|
||||||
|
with patch('api.customs.management.commands.dedup_documents.storage_service') as st2:
|
||||||
|
st2.file_exists.return_value = True
|
||||||
|
call_command('dedup_documents', pedimento=str(self.pedimento.id), stdout=StringIO())
|
||||||
|
self.assertEqual(st2.delete_file.call_count, 0)
|
||||||
|
|
||||||
|
def test_dedup_dry_run_no_borra(self):
|
||||||
|
from unittest.mock import patch
|
||||||
|
self._tres_copias_edoc()
|
||||||
|
with patch('api.customs.management.commands.dedup_documents.storage_service') as st:
|
||||||
|
call_command('dedup_documents', pedimento=str(self.pedimento.id), dry_run=True, stdout=StringIO())
|
||||||
|
self.assertEqual(st.delete_file.call_count, 0)
|
||||||
|
self.assertEqual(Document.objects.filter(edocument=self.edoc, document_type_id=5).count(), 3)
|
||||||
|
|
||||||
|
def test_dedup_conserva_el_que_tiene_archivo_en_storage(self):
|
||||||
|
from unittest.mock import patch
|
||||||
|
d1, d2, d3 = self._tres_copias_edoc() # d3 el más reciente
|
||||||
|
# En storage solo existe el de d1 (el más viejo); los más nuevos no.
|
||||||
|
with patch('api.customs.management.commands.dedup_documents.storage_service') as st:
|
||||||
|
st.file_exists.side_effect = lambda nombre: nombre.endswith('_aaa.pdf')
|
||||||
|
call_command('dedup_documents', pedimento=str(self.pedimento.id), stdout=StringIO())
|
||||||
|
restantes = list(Document.objects.filter(edocument=self.edoc, document_type_id=5))
|
||||||
|
self.assertEqual(len(restantes), 1)
|
||||||
|
self.assertEqual(restantes[0].id, d1.id) # conservó el único con archivo válido
|
||||||
|
|
||||||
|
def test_backfill_legacy_liga_por_numero(self):
|
||||||
|
# Doc legado: app y prefijo viejos (vu_EDC, otro pedimento_app), pero el
|
||||||
|
# numero_edocument (EDOC001) SÍ está en el nombre.
|
||||||
|
legado = self._doc("vu_EDC_0201_800_3452_5000586_EDOC001_abc123.pdf", 5)
|
||||||
|
legado.refresh_from_db()
|
||||||
|
self.assertIsNone(legado.edocument_id) # save() (match estricto) no lo ligó
|
||||||
|
# el backfill estricto tampoco (app/prefijo no coinciden)
|
||||||
|
call_command("backfill_document_links", pedimento=str(self.pedimento.id), stdout=StringIO())
|
||||||
|
legado.refresh_from_db()
|
||||||
|
self.assertIsNone(legado.edocument_id)
|
||||||
|
# el backfill LEGADO sí lo liga por número único
|
||||||
|
call_command("backfill_document_links_legacy", pedimento=str(self.pedimento.id), stdout=StringIO())
|
||||||
|
legado.refresh_from_db()
|
||||||
|
self.assertEqual(legado.edocument_id, self.edoc.id)
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ from .views_auditor import (
|
|||||||
auditar_acuse_cove_endpoint,
|
auditar_acuse_cove_endpoint,
|
||||||
auditar_edocuments_endpoint,
|
auditar_edocuments_endpoint,
|
||||||
auditar_acuse_endpoint,
|
auditar_acuse_endpoint,
|
||||||
|
auditar_remesas_endpoint,
|
||||||
auditar_cove_pedimento_endpoint,
|
auditar_cove_pedimento_endpoint,
|
||||||
auditar_acuse_cove_pedimento_endpoint,
|
auditar_acuse_cove_pedimento_endpoint,
|
||||||
auditar_edocument_pedimento_endpoint,
|
auditar_edocument_pedimento_endpoint,
|
||||||
@@ -61,6 +62,27 @@ from .views_auditor import (
|
|||||||
auditor_obtener_peticion_edocument_vu,
|
auditor_obtener_peticion_edocument_vu,
|
||||||
auditor_obtener_respuesta_edocument_vu,
|
auditor_obtener_respuesta_edocument_vu,
|
||||||
auditar_pedimento_endpoint,
|
auditar_pedimento_endpoint,
|
||||||
|
procesar_pedimento_completo_endpoint,
|
||||||
|
auto_corregir_pedamentos_endpoint,
|
||||||
|
auditar_pedamentos_incompletos_endpoint,
|
||||||
|
auditar_pedamento_incompleto_endpoint,
|
||||||
|
auto_corregir_pedamento_endpoint,
|
||||||
|
auditar_integridad_partidas_endpoint,
|
||||||
|
auditar_integridad_partidas_pedimento_endpoint,
|
||||||
|
auditar_integridad_edocuments_endpoint,
|
||||||
|
auditar_integridad_edocuments_pedimento_endpoint,
|
||||||
|
auditar_integridad_coves_endpoint,
|
||||||
|
auditar_integridad_coves_pedimento_endpoint,
|
||||||
|
auditar_integridad_remesa_endpoint,
|
||||||
|
auditar_integridad_remesa_pedimento_endpoint,
|
||||||
|
corregir_integridad_partidas_endpoint,
|
||||||
|
corregir_integridad_partidas_pedimento_endpoint,
|
||||||
|
corregir_integridad_edocuments_endpoint,
|
||||||
|
corregir_integridad_edocuments_pedimento_endpoint,
|
||||||
|
corregir_integridad_coves_endpoint,
|
||||||
|
corregir_integridad_coves_pedimento_endpoint,
|
||||||
|
corregir_integridad_remesa_endpoint,
|
||||||
|
corregir_integridad_remesa_pedimento_endpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
@@ -72,12 +94,18 @@ urlpatterns = [
|
|||||||
path('auditor/auditar-acuse-cove/', auditar_acuse_cove_endpoint, name='auditar-acuse-cove'),
|
path('auditor/auditar-acuse-cove/', auditar_acuse_cove_endpoint, name='auditar-acuse-cove'),
|
||||||
path('auditor/auditar-edocuments/', auditar_edocuments_endpoint, name='auditar-edocuments'),
|
path('auditor/auditar-edocuments/', auditar_edocuments_endpoint, name='auditar-edocuments'),
|
||||||
path('auditor/auditar-acuse/', auditar_acuse_endpoint, name='auditar-acuse'),
|
path('auditor/auditar-acuse/', auditar_acuse_endpoint, name='auditar-acuse'),
|
||||||
|
path('auditor/auditar-remesas/', auditar_remesas_endpoint, name='auditar-remesas'),
|
||||||
path('auditor/auditar-cove/pedimento/', auditar_cove_pedimento_endpoint, name='auditar-cove-pedimento'),
|
path('auditor/auditar-cove/pedimento/', auditar_cove_pedimento_endpoint, name='auditar-cove-pedimento'),
|
||||||
path('auditor/auditar-acuse-cove/pedimento/', auditar_acuse_cove_pedimento_endpoint, name='auditar-acuse-cove-pedimento'),
|
path('auditor/auditar-acuse-cove/pedimento/', auditar_acuse_cove_pedimento_endpoint, name='auditar-acuse-cove-pedimento'),
|
||||||
path('auditor/auditar-edocument/pedimento/', auditar_edocument_pedimento_endpoint, name='auditar-edocument-pedimento'),
|
path('auditor/auditar-edocument/pedimento/', auditar_edocument_pedimento_endpoint, name='auditar-edocument-pedimento'),
|
||||||
path('auditor/auditar-acuse/pedimento/', auditar_acuse_pedimento_endpoint, name='auditar-acuse-pedimento'),
|
path('auditor/auditar-acuse/pedimento/', auditar_acuse_pedimento_endpoint, name='auditar-acuse-pedimento'),
|
||||||
path('auditor/auditar-remesa/pedimento/', auditar_procesamiento_remesa_pedimento_endpoint, name='auditar-remesa-pedimento'),
|
path('auditor/auditar-remesa/pedimento/', auditar_procesamiento_remesa_pedimento_endpoint, name='auditar-remesa-pedimento'),
|
||||||
path('auditor/auditar-pedimento/', auditar_pedimento_endpoint, name='auditar-pedimento'),
|
path('auditor/auditar-pedimento/', auditar_pedimento_endpoint, name='auditar-pedimento'),
|
||||||
|
path('auditor/procesar-pedimento-completo/pedimento/', procesar_pedimento_completo_endpoint, name='procesar-pedimento-completo-pedimento'),
|
||||||
|
path('auditor/auto-corregir-pedamentos/', auto_corregir_pedamentos_endpoint, name='auto-corregir-pedamentos'),
|
||||||
|
path('auditor/auditar-pedamentos-incompletos/', auditar_pedamentos_incompletos_endpoint, name='auditar-pedamentos-incompletos'),
|
||||||
|
path('auditor/auto-corregir-pedamento/', auto_corregir_pedamento_endpoint, name='auto-corregir-pedamento'),
|
||||||
|
path('auditor/auditar-pedamento-incompleto/', auditar_pedamento_incompleto_endpoint, name='auditar-pedamento-incompleto'),
|
||||||
|
|
||||||
path('auditor/procesar-pedimentos/organizaciones/', auditor_procesar_pedimentos_organizacion, name='procesar-pedimentos-organizaciones'),
|
path('auditor/procesar-pedimentos/organizaciones/', auditor_procesar_pedimentos_organizacion, name='procesar-pedimentos-organizaciones'),
|
||||||
path('auditor/peticion-respuesta/pedimento-vu/', auditar_peticion_respuesta_pedimento_completo, name='peticion-respuesta-pedimento-vu'),
|
path('auditor/peticion-respuesta/pedimento-vu/', auditar_peticion_respuesta_pedimento_completo, name='peticion-respuesta-pedimento-vu'),
|
||||||
@@ -99,4 +127,22 @@ urlpatterns = [
|
|||||||
|
|
||||||
path('procesamientopedimentos-ejecutar-comando/', EjecutarComandoView.as_view(), name='procesamientopedimentos-ejecutar-comando'),
|
path('procesamientopedimentos-ejecutar-comando/', EjecutarComandoView.as_view(), name='procesamientopedimentos-ejecutar-comando'),
|
||||||
|
|
||||||
|
path('auditor/auditar-integridad-partidas/', auditar_integridad_partidas_endpoint, name='auditar-integridad-partidas'),
|
||||||
|
path('auditor/auditar-integridad-partidas/pedimento/', auditar_integridad_partidas_pedimento_endpoint, name='auditar-integridad-partidas-pedimento'),
|
||||||
|
path('auditor/auditar-integridad-edocuments/', auditar_integridad_edocuments_endpoint, name='auditar-integridad-edocuments'),
|
||||||
|
path('auditor/auditar-integridad-edocuments/pedimento/', auditar_integridad_edocuments_pedimento_endpoint, name='auditar-integridad-edocuments-pedimento'),
|
||||||
|
path('auditor/auditar-integridad-coves/', auditar_integridad_coves_endpoint, name='auditar-integridad-coves'),
|
||||||
|
path('auditor/auditar-integridad-coves/pedimento/', auditar_integridad_coves_pedimento_endpoint, name='auditar-integridad-coves-pedimento'),
|
||||||
|
path('auditor/auditar-integridad-remesa/', auditar_integridad_remesa_endpoint, name='auditar-integridad-remesa'),
|
||||||
|
path('auditor/auditar-integridad-remesa/pedimento/', auditar_integridad_remesa_pedimento_endpoint, name='auditar-integridad-remesa-pedimento'),
|
||||||
|
|
||||||
|
path('auditor/corregir-integridad-partidas/', corregir_integridad_partidas_endpoint, name='corregir-integridad-partidas'),
|
||||||
|
path('auditor/corregir-integridad-partidas/pedimento/', corregir_integridad_partidas_pedimento_endpoint, name='corregir-integridad-partidas-pedimento'),
|
||||||
|
path('auditor/corregir-integridad-edocuments/', corregir_integridad_edocuments_endpoint, name='corregir-integridad-edocuments'),
|
||||||
|
path('auditor/corregir-integridad-edocuments/pedimento/', corregir_integridad_edocuments_pedimento_endpoint, name='corregir-integridad-edocuments-pedimento'),
|
||||||
|
path('auditor/corregir-integridad-coves/', corregir_integridad_coves_endpoint, name='corregir-integridad-coves'),
|
||||||
|
path('auditor/corregir-integridad-coves/pedimento/', corregir_integridad_coves_pedimento_endpoint, name='corregir-integridad-coves-pedimento'),
|
||||||
|
path('auditor/corregir-integridad-remesa/', corregir_integridad_remesa_endpoint, name='corregir-integridad-remesa'),
|
||||||
|
path('auditor/corregir-integridad-remesa/pedimento/', corregir_integridad_remesa_pedimento_endpoint, name='corregir-integridad-remesa-pedimento'),
|
||||||
|
|
||||||
]
|
]
|
||||||
2554
api/customs/views.py
2554
api/customs/views.py
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
0
api/datastage/management/__init__.py
Normal file
0
api/datastage/management/__init__.py
Normal file
0
api/datastage/management/commands/__init__.py
Normal file
0
api/datastage/management/commands/__init__.py
Normal file
195
api/datastage/management/commands/reprocesar_datastages.py
Normal file
195
api/datastage/management/commands/reprocesar_datastages.py
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
"""
|
||||||
|
Reprocesa datastages ya cargados: elimina los Registro* existentes del datastage
|
||||||
|
y reprocesa los archivos .asc de forma SINCRÓNICA (sin Celery).
|
||||||
|
|
||||||
|
Casos de uso:
|
||||||
|
- Los registros quedaron vacíos por un bug y ya fue corregido.
|
||||||
|
- Se quiere refrescar los datos sin que el usuario vuelva a subir el archivo.
|
||||||
|
|
||||||
|
Los Pedimentos existentes NO se tocan (el create en la task falla silenciosamente
|
||||||
|
por unique_together si ya existen).
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
python manage.py reprocesar_datastages # todos los datastages
|
||||||
|
python manage.py reprocesar_datastages --organizacion <UUID> # solo una org
|
||||||
|
python manage.py reprocesar_datastages --datastage 4 7 12 # IDs específicos
|
||||||
|
python manage.py reprocesar_datastages --organizacion <UUID> --datastage 4
|
||||||
|
python manage.py reprocesar_datastages --dry-run # sin cambios
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
|
||||||
|
from api.datastage.models import (
|
||||||
|
DataStage,
|
||||||
|
Registro500, Registro501, Registro502, Registro503, Registro504,
|
||||||
|
Registro505, Registro506, Registro507, Registro508, Registro509,
|
||||||
|
Registro510, Registro511, Registro512, Registro520,
|
||||||
|
Registro551, Registro552, Registro553, Registro554, Registro555,
|
||||||
|
Registro556, Registro557, Registro558,
|
||||||
|
RegistroSel,
|
||||||
|
Registro701, Registro702,
|
||||||
|
)
|
||||||
|
|
||||||
|
REGISTRO_MODELS = [
|
||||||
|
Registro500, Registro501, Registro502, Registro503, Registro504,
|
||||||
|
Registro505, Registro506, Registro507, Registro508, Registro509,
|
||||||
|
Registro510, Registro511, Registro512, Registro520,
|
||||||
|
Registro551, Registro552, Registro553, Registro554, Registro555,
|
||||||
|
Registro556, Registro557, Registro558,
|
||||||
|
RegistroSel,
|
||||||
|
Registro701, Registro702,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Elimina los Registro* de datastages procesados y vuelve a procesarlos de forma sincrónica."
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"--organizacion", metavar="UUID",
|
||||||
|
help="UUID de la organización. Sin este arg: todas las orgs.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--datastage", metavar="ID", nargs="+", type=int,
|
||||||
|
help="Uno o más IDs de DataStage a reprocesar.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run", action="store_true",
|
||||||
|
help="Solo muestra lo que haría, sin borrar ni insertar.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
org_id = options.get("organizacion")
|
||||||
|
ds_ids = options.get("datastage")
|
||||||
|
dry_run = options["dry_run"]
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
self.stdout.write(self.style.WARNING(
|
||||||
|
"=== MODO PRUEBA (--dry-run): sin cambios en BD ===\n"
|
||||||
|
))
|
||||||
|
|
||||||
|
qs = DataStage.objects.select_related("organizacion").order_by("id")
|
||||||
|
if org_id:
|
||||||
|
qs = qs.filter(organizacion_id=org_id)
|
||||||
|
if ds_ids:
|
||||||
|
qs = qs.filter(id__in=ds_ids)
|
||||||
|
|
||||||
|
total = qs.count()
|
||||||
|
if total == 0:
|
||||||
|
self.stdout.write(self.style.WARNING("No se encontraron datastages con los filtros indicados."))
|
||||||
|
return
|
||||||
|
|
||||||
|
self.stdout.write(f"Datastages a reprocesar: {total}\n")
|
||||||
|
|
||||||
|
ok = err = 0
|
||||||
|
for ds in qs:
|
||||||
|
exito = self._reprocesar(ds, dry_run)
|
||||||
|
if exito:
|
||||||
|
ok += 1
|
||||||
|
else:
|
||||||
|
err += 1
|
||||||
|
|
||||||
|
self._print_summary(ok, err, dry_run)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def _reprocesar(self, ds, dry_run):
|
||||||
|
org_nombre = ds.organizacion.nombre if ds.organizacion else "sin organización"
|
||||||
|
self.stdout.write(
|
||||||
|
f"\nDataStage ID={ds.id} | org={org_nombre} | archivo={ds.archivo or '—'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not ds.archivo:
|
||||||
|
self.stdout.write(self.style.ERROR(" → Sin archivo asociado, se omite."))
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 1. Eliminar Registro* existentes
|
||||||
|
total_borrados = 0
|
||||||
|
for Model in REGISTRO_MODELS:
|
||||||
|
qs_modelo = Model.objects.filter(datastage=ds)
|
||||||
|
count = qs_modelo.count()
|
||||||
|
if count == 0:
|
||||||
|
continue
|
||||||
|
if not dry_run:
|
||||||
|
qs_modelo.delete()
|
||||||
|
estado = "[dry-run]" if dry_run else "borrados"
|
||||||
|
self.stdout.write(f" {Model.__name__}: {count} {estado}")
|
||||||
|
total_borrados += count
|
||||||
|
|
||||||
|
if total_borrados == 0:
|
||||||
|
self.stdout.write(" → Sin registros existentes en ninguna tabla.")
|
||||||
|
else:
|
||||||
|
self.stdout.write(f" Total eliminados: {total_borrados}")
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
self.stdout.write(self.style.WARNING(
|
||||||
|
" → [dry-run] Se procesarían los archivos .asc del datastage."
|
||||||
|
))
|
||||||
|
return True
|
||||||
|
|
||||||
|
# 2. Descargar ZIP una vez para obtener la lista de .asc
|
||||||
|
from api.utils.storage_service import storage_service
|
||||||
|
|
||||||
|
ruta = str(ds.archivo)
|
||||||
|
if not storage_service.file_exists(ruta):
|
||||||
|
self.stdout.write(self.style.ERROR(
|
||||||
|
f" El archivo no existe en storage: {ruta}"
|
||||||
|
))
|
||||||
|
return False
|
||||||
|
|
||||||
|
tmp_path = None
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
|
||||||
|
if not storage_service.download_file(ruta, tmp_path):
|
||||||
|
self.stdout.write(self.style.ERROR(
|
||||||
|
f" No se pudo descargar '{ruta}' — verifica conectividad con MinIO."
|
||||||
|
))
|
||||||
|
return False
|
||||||
|
|
||||||
|
with zipfile.ZipFile(tmp_path, "r") as zf:
|
||||||
|
asc_files = [n for n in zf.namelist() if n.endswith(".asc")]
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if tmp_path and os.path.exists(tmp_path):
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
|
||||||
|
if not asc_files:
|
||||||
|
self.stdout.write(self.style.WARNING(" → No se encontraron archivos .asc en el ZIP."))
|
||||||
|
return True
|
||||||
|
|
||||||
|
self.stdout.write(f" Archivos .asc encontrados: {len(asc_files)}")
|
||||||
|
|
||||||
|
# 3. Procesar cada .asc de forma sincrónica (sin Celery)
|
||||||
|
from api.datastage.tasks import procesar_archivo_asc_task
|
||||||
|
|
||||||
|
total_insertados = 0
|
||||||
|
for asc_name in asc_files:
|
||||||
|
self.stdout.write(f" {asc_name} ... ", ending="")
|
||||||
|
result = procesar_archivo_asc_task(ds.id, ds.organizacion_id, asc_name)
|
||||||
|
if "error" in result:
|
||||||
|
self.stdout.write(self.style.ERROR(f"ERROR: {result['error']}"))
|
||||||
|
else:
|
||||||
|
insertados = result.get("insertados", 0)
|
||||||
|
total_insertados += insertados
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"{insertados} registros"))
|
||||||
|
|
||||||
|
self.stdout.write(f" Total insertados: {total_insertados}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def _print_summary(self, ok, err, dry_run):
|
||||||
|
self.stdout.write(f"\n{'─' * 60}")
|
||||||
|
self.stdout.write(f"RESUMEN: {ok} exitosos, {err} con error.")
|
||||||
|
if dry_run:
|
||||||
|
self.stdout.write(self.style.WARNING(
|
||||||
|
"MODO PRUEBA: ejecuta sin --dry-run para aplicar los cambios."
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
self.stdout.write(self.style.SUCCESS("Reprocesado completado."))
|
||||||
18
api/datastage/migrations/0012_alter_datastage_archivo.py
Normal file
18
api/datastage/migrations/0012_alter_datastage_archivo.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.3 on 2026-04-20 16:34
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('datastage', '0011_alter_registro502_fecha_pago_real_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='datastage',
|
||||||
|
name='archivo',
|
||||||
|
field=models.CharField(blank=True, max_length=500, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
26
api/datastage/migrations/0013_registro501_add_timestamps.py
Normal file
26
api/datastage/migrations/0013_registro501_add_timestamps.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
import django.utils.timezone
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('datastage', '0012_alter_datastage_archivo'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# La columna created_at ya existe en la BD (NOT NULL, sin DEFAULT).
|
||||||
|
# Solo actualizamos el estado interno de Django para que auto_now_add
|
||||||
|
# inserte el valor al hacer bulk_create.
|
||||||
|
migrations.SeparateDatabaseAndState(
|
||||||
|
state_operations=[
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='registro501',
|
||||||
|
name='created_at',
|
||||||
|
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
database_operations=[],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
import django.utils.timezone
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
"""
|
||||||
|
Las columnas created_at ya existen en la BD como NOT NULL sin DEFAULT.
|
||||||
|
Solo actualizamos el estado interno de Django para que auto_now_add
|
||||||
|
inserte el timestamp al hacer bulk_create.
|
||||||
|
"""
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('datastage', '0013_registro501_add_timestamps'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.SeparateDatabaseAndState(
|
||||||
|
state_operations=[
|
||||||
|
migrations.AddField(model_name='registro502', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||||
|
migrations.AddField(model_name='registro503', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||||
|
migrations.AddField(model_name='registro504', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||||
|
migrations.AddField(model_name='registro505', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||||
|
migrations.AddField(model_name='registro506', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||||
|
migrations.AddField(model_name='registro507', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||||
|
migrations.AddField(model_name='registro508', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||||
|
migrations.AddField(model_name='registro509', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||||
|
migrations.AddField(model_name='registro510', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||||
|
migrations.AddField(model_name='registro511', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||||
|
migrations.AddField(model_name='registro512', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||||
|
migrations.AddField(model_name='registro551', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||||
|
migrations.AddField(model_name='registro552', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||||
|
migrations.AddField(model_name='registro553', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||||
|
migrations.AddField(model_name='registro554', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||||
|
migrations.AddField(model_name='registro555', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||||
|
migrations.AddField(model_name='registro556', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||||
|
migrations.AddField(model_name='registro557', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||||
|
migrations.AddField(model_name='registro558', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||||
|
migrations.AddField(model_name='registrosel', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||||
|
migrations.AddField(model_name='registro701', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||||
|
migrations.AddField(model_name='registro702', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||||
|
],
|
||||||
|
database_operations=[],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -3,7 +3,8 @@ from django.db import models
|
|||||||
# Create your models here.
|
# Create your models here.
|
||||||
class DataStage(models.Model):
|
class DataStage(models.Model):
|
||||||
organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='datastages', null=True, blank=True)
|
organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='datastages', null=True, blank=True)
|
||||||
archivo = models.FileField(upload_to='datastages/', blank=False, null=False)
|
# archivo = models.FileField(upload_to='datastages/', blank=False, null=False)
|
||||||
|
archivo = models.CharField(max_length=500, blank=True, null=True)
|
||||||
contribuyente = models.CharField(max_length=100, blank=False, null=False)
|
contribuyente = models.CharField(max_length=100, blank=False, null=False)
|
||||||
procesado = models.BooleanField(default=False)
|
procesado = models.BooleanField(default=False)
|
||||||
|
|
||||||
@@ -84,6 +85,8 @@ class Registro501(models.Model):
|
|||||||
consulta = models.CharField(max_length=50, null=True, blank=True)
|
consulta = models.CharField(max_length=50, null=True, blank=True)
|
||||||
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro501s', null=True, blank=True)
|
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro501s', null=True, blank=True)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'registro501'
|
db_table = 'registro501'
|
||||||
|
|
||||||
@@ -103,6 +106,8 @@ class Registro502(models.Model):
|
|||||||
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro502s', null=True, blank=True)
|
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro502s', null=True, blank=True)
|
||||||
patente = models.CharField(max_length=50, null=True, blank=True)
|
patente = models.CharField(max_length=50, null=True, blank=True)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'registro502'
|
db_table = 'registro502'
|
||||||
|
|
||||||
@@ -119,6 +124,8 @@ class Registro503(models.Model):
|
|||||||
consulta = models.CharField(max_length=50, null=True, blank=True)
|
consulta = models.CharField(max_length=50, null=True, blank=True)
|
||||||
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro503s', null=True, blank=True)
|
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro503s', null=True, blank=True)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'registro503'
|
db_table = 'registro503'
|
||||||
|
|
||||||
@@ -135,6 +142,8 @@ class Registro504(models.Model):
|
|||||||
consulta = models.CharField(max_length=50, null=True, blank=True)
|
consulta = models.CharField(max_length=50, null=True, blank=True)
|
||||||
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro504s', null=True, blank=True)
|
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro504s', null=True, blank=True)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'registro504'
|
db_table = 'registro504'
|
||||||
|
|
||||||
@@ -164,6 +173,8 @@ class Registro505(models.Model):
|
|||||||
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro505s', null=True, blank=True)
|
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro505s', null=True, blank=True)
|
||||||
patente = models.CharField(max_length=50, null=True, blank=True)
|
patente = models.CharField(max_length=50, null=True, blank=True)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'registro505'
|
db_table = 'registro505'
|
||||||
|
|
||||||
@@ -180,6 +191,8 @@ class Registro506(models.Model):
|
|||||||
consulta = models.CharField(max_length=50, null=True, blank=True)
|
consulta = models.CharField(max_length=50, null=True, blank=True)
|
||||||
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro506s', null=True, blank=True)
|
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro506s', null=True, blank=True)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'registro506'
|
db_table = 'registro506'
|
||||||
|
|
||||||
@@ -198,6 +211,8 @@ class Registro507(models.Model):
|
|||||||
consulta = models.CharField(max_length=50, null=True, blank=True)
|
consulta = models.CharField(max_length=50, null=True, blank=True)
|
||||||
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro507s', null=True, blank=True)
|
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro507s', null=True, blank=True)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'registro507'
|
db_table = 'registro507'
|
||||||
|
|
||||||
@@ -222,6 +237,8 @@ class Registro508(models.Model):
|
|||||||
consulta = models.CharField(max_length=50, null=True, blank=True)
|
consulta = models.CharField(max_length=50, null=True, blank=True)
|
||||||
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro508s', null=True, blank=True)
|
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro508s', null=True, blank=True)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'registro508'
|
db_table = 'registro508'
|
||||||
|
|
||||||
@@ -240,6 +257,8 @@ class Registro509(models.Model):
|
|||||||
consulta = models.CharField(max_length=50, null=True, blank=True)
|
consulta = models.CharField(max_length=50, null=True, blank=True)
|
||||||
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro509s', null=True, blank=True)
|
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro509s', null=True, blank=True)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'registro509'
|
db_table = 'registro509'
|
||||||
|
|
||||||
@@ -260,6 +279,8 @@ class Registro510(models.Model):
|
|||||||
forma_pago = models.CharField(max_length=3, null=True, blank=True)
|
forma_pago = models.CharField(max_length=3, null=True, blank=True)
|
||||||
importe_pago = models.CharField(max_length=12, null=True, blank=True)
|
importe_pago = models.CharField(max_length=12, null=True, blank=True)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'registro510'
|
db_table = 'registro510'
|
||||||
|
|
||||||
@@ -277,6 +298,8 @@ class Registro511(models.Model):
|
|||||||
consulta = models.CharField(max_length=50, null=True, blank=True)
|
consulta = models.CharField(max_length=50, null=True, blank=True)
|
||||||
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro511s', null=True, blank=True)
|
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro511s', null=True, blank=True)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'registro511'
|
db_table = 'registro511'
|
||||||
|
|
||||||
@@ -300,6 +323,8 @@ class Registro512(models.Model):
|
|||||||
consulta = models.CharField(max_length=50, null=True, blank=True)
|
consulta = models.CharField(max_length=50, null=True, blank=True)
|
||||||
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro512s', null=True, blank=True)
|
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro512s', null=True, blank=True)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'registro512'
|
db_table = 'registro512'
|
||||||
|
|
||||||
@@ -362,6 +387,8 @@ class Registro551(models.Model):
|
|||||||
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro551s', null=True, blank=True)
|
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro551s', null=True, blank=True)
|
||||||
entidad_fed_destino = models.CharField(max_length=50, null=True, blank=True)
|
entidad_fed_destino = models.CharField(max_length=50, null=True, blank=True)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'registro551'
|
db_table = 'registro551'
|
||||||
|
|
||||||
@@ -380,6 +407,8 @@ class Registro552(models.Model):
|
|||||||
consulta = models.CharField(max_length=50, null=True, blank=True)
|
consulta = models.CharField(max_length=50, null=True, blank=True)
|
||||||
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro552s', null=True, blank=True)
|
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro552s', null=True, blank=True)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'registro552'
|
db_table = 'registro552'
|
||||||
|
|
||||||
@@ -401,6 +430,8 @@ class Registro553(models.Model):
|
|||||||
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro553s', null=True, blank=True)
|
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro553s', null=True, blank=True)
|
||||||
consulta = models.CharField(max_length=50, null=True, blank=True)
|
consulta = models.CharField(max_length=50, null=True, blank=True)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'registro553'
|
db_table = 'registro553'
|
||||||
|
|
||||||
@@ -420,6 +451,8 @@ class Registro554(models.Model):
|
|||||||
consulta = models.CharField(max_length=50, null=True, blank=True)
|
consulta = models.CharField(max_length=50, null=True, blank=True)
|
||||||
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro554s', null=True, blank=True)
|
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro554s', null=True, blank=True)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'registro554'
|
db_table = 'registro554'
|
||||||
|
|
||||||
@@ -445,6 +478,8 @@ class Registro555(models.Model):
|
|||||||
created_by = models.IntegerField(null=True, blank=True)
|
created_by = models.IntegerField(null=True, blank=True)
|
||||||
consulta = models.CharField(max_length=50, null=True, blank=True)
|
consulta = models.CharField(max_length=50, null=True, blank=True)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'registro555'
|
db_table = 'registro555'
|
||||||
|
|
||||||
@@ -464,6 +499,8 @@ class Registro556(models.Model):
|
|||||||
fraccion = models.CharField(max_length=8, null=True, blank=True)
|
fraccion = models.CharField(max_length=8, null=True, blank=True)
|
||||||
secuencia_fraccion = models.CharField(max_length=50, null=True, blank=True)
|
secuencia_fraccion = models.CharField(max_length=50, null=True, blank=True)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'registro556'
|
db_table = 'registro556'
|
||||||
|
|
||||||
@@ -483,6 +520,8 @@ class Registro557(models.Model):
|
|||||||
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro557s', null=True, blank=True)
|
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro557s', null=True, blank=True)
|
||||||
consulta = models.CharField(max_length=50, null=True, blank=True)
|
consulta = models.CharField(max_length=50, null=True, blank=True)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'registro557'
|
db_table = 'registro557'
|
||||||
|
|
||||||
@@ -501,6 +540,8 @@ class Registro558(models.Model):
|
|||||||
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro558s', null=True, blank=True)
|
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro558s', null=True, blank=True)
|
||||||
consulta = models.CharField(max_length=50, null=True, blank=True)
|
consulta = models.CharField(max_length=50, null=True, blank=True)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'registro558'
|
db_table = 'registro558'
|
||||||
|
|
||||||
@@ -521,6 +562,8 @@ class RegistroSel(models.Model):
|
|||||||
consulta = models.CharField(max_length=50, null=True, blank=True)
|
consulta = models.CharField(max_length=50, null=True, blank=True)
|
||||||
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro_sel', null=True, blank=True)
|
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro_sel', null=True, blank=True)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'registro_sel'
|
db_table = 'registro_sel'
|
||||||
|
|
||||||
@@ -545,6 +588,8 @@ class Registro701(models.Model):
|
|||||||
consulta = models.CharField(max_length=50, null=True, blank=True)
|
consulta = models.CharField(max_length=50, null=True, blank=True)
|
||||||
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro701s', null=True, blank=True)
|
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro701s', null=True, blank=True)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'registro701'
|
db_table = 'registro701'
|
||||||
|
|
||||||
@@ -563,6 +608,8 @@ class Registro702(models.Model):
|
|||||||
consulta = models.CharField(max_length=50, null=True, blank=True)
|
consulta = models.CharField(max_length=50, null=True, blank=True)
|
||||||
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro702s', null=True, blank=True)
|
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro702s', null=True, blank=True)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'registro702'
|
db_table = 'registro702'
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,86 @@
|
|||||||
|
from api.utils.storage_service import storage_service
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from .models import DataStage
|
from .models import DataStage
|
||||||
from api.organization.models import Organizacion
|
from api.organization.models import Organizacion
|
||||||
|
|
||||||
class DataStageSerializer(serializers.ModelSerializer):
|
class DataStageSerializer(serializers.ModelSerializer):
|
||||||
|
archivo = serializers.FileField(write_only=True, required=False, allow_null=True)
|
||||||
|
download_url = serializers.SerializerMethodField(read_only=True)
|
||||||
|
|
||||||
organizacion = serializers.PrimaryKeyRelatedField(required=False, allow_null=True, queryset=Organizacion.objects.all())
|
organizacion = serializers.PrimaryKeyRelatedField(required=False, allow_null=True, queryset=Organizacion.objects.all())
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = DataStage
|
model = DataStage
|
||||||
fields = '__all__'
|
fields = '__all__'
|
||||||
read_only_fields = ('id', 'created_at', 'updated_at')
|
read_only_fields = ('id', 'created_at', 'updated_at')
|
||||||
|
# extra_kwargs = {'archivo': {'read_only': True},}
|
||||||
|
|
||||||
|
def get_download_url(self, obj):
|
||||||
|
"""Retorna URL de descarga según dónde esté el archivo"""
|
||||||
|
if not obj.archivo:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if storage_service.is_minio_path(obj.archivo):
|
||||||
|
return storage_service.get_file_url(obj.archivo)
|
||||||
|
else:
|
||||||
|
request = self.context.get('request')
|
||||||
|
if request:
|
||||||
|
return request.build_absolute_uri(
|
||||||
|
f"/api/v1/datastage/datastages/{obj.id}/download-datastage/"
|
||||||
|
)
|
||||||
|
return f"/api/v1/datastage/datastages/{obj.id}/download-datastage/"
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
"""Override para manejar la subida del archivo a MinIO"""
|
||||||
|
archivo_file = validated_data.pop('archivo', None)
|
||||||
|
organizacion = validated_data.get('organizacion')
|
||||||
|
datastage = super().create(validated_data)
|
||||||
|
print(f"ENDPOINT DE CREATE >>>>")
|
||||||
|
# guardarlo en MinIO
|
||||||
|
if archivo_file:
|
||||||
|
ruta = storage_service.save_datastage(
|
||||||
|
file=archivo_file,
|
||||||
|
organizacion_id=organizacion.id if organizacion else datastage.organizacion.id,
|
||||||
|
metadata={
|
||||||
|
'datastage_id': str(datastage.id),
|
||||||
|
'nombre': datastage.nombre if hasattr(datastage, 'nombre') else ''
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if ruta:
|
||||||
|
datastage.archivo = ruta
|
||||||
|
datastage.save()
|
||||||
|
else:
|
||||||
|
# eliminar el registro creado
|
||||||
|
datastage.delete()
|
||||||
|
raise serializers.ValidationError({"archivo": "Error al guardar el archivo en el almacenamiento"})
|
||||||
|
|
||||||
|
return datastage
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
"""Override para manejar actualización de archivo"""
|
||||||
|
archivo_file = validated_data.pop('archivo', None)
|
||||||
|
organizacion = validated_data.get('organizacion', instance.organizacion)
|
||||||
|
instance = super().update(instance, validated_data)
|
||||||
|
|
||||||
|
# Si hay nuevo archivo, reemplazarlo
|
||||||
|
if archivo_file:
|
||||||
|
if instance.archivo:
|
||||||
|
storage_service.delete_file(instance.archivo)
|
||||||
|
|
||||||
|
ruta = storage_service.save_datastage(
|
||||||
|
file=archivo_file,
|
||||||
|
organizacion_id=organizacion.id,
|
||||||
|
metadata={
|
||||||
|
'datastage_id': str(instance.id),
|
||||||
|
'updated': 'true'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if ruta:
|
||||||
|
instance.archivo = ruta
|
||||||
|
instance.save()
|
||||||
|
else:
|
||||||
|
raise serializers.ValidationError({"archivo": "Error al guardar el nuevo archivo"})
|
||||||
|
return instance
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import tempfile
|
||||||
from celery import group
|
from celery import group
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
import logging
|
import logging
|
||||||
@@ -6,81 +7,132 @@ from django.utils import timezone
|
|||||||
import os
|
import os
|
||||||
import zipfile
|
import zipfile
|
||||||
import re
|
import re
|
||||||
|
from api.utils.storage_service import storage_service
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def procesar_datastage_task(datastage_id, user_organizacion_id=None):
|
def procesar_datastage_task(datastage_id, user_organizacion_id=None):
|
||||||
import traceback
|
import traceback
|
||||||
|
tmp_path = None
|
||||||
try:
|
try:
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
from api.datastage.models import DataStage
|
from api.datastage.models import DataStage
|
||||||
from api.organization.models import Organizacion
|
from api.organization.models import Organizacion
|
||||||
from api.customs.models import Pedimento, TipoOperacion, Regimen
|
|
||||||
|
|
||||||
datastage = DataStage.objects.get(id=datastage_id)
|
# Obtener datastage
|
||||||
|
try:
|
||||||
|
datastage = DataStage.objects.get(id=datastage_id)
|
||||||
|
except DataStage.DoesNotExist:
|
||||||
|
return {'error': f'DataStage {datastage_id} no encontrado'}
|
||||||
|
|
||||||
|
# Validar archivo
|
||||||
if not datastage.archivo:
|
if not datastage.archivo:
|
||||||
|
print("DataStage no tiene archivo asociado")
|
||||||
return {'detail': 'No hay archivo asociado a este DataStage.'}
|
return {'detail': 'No hay archivo asociado a este DataStage.'}
|
||||||
file_path = datastage.archivo.path
|
|
||||||
if not os.path.exists(file_path):
|
ruta_archivo = str(datastage.archivo)
|
||||||
return {'detail': 'El archivo no existe en el servidor.'}
|
|
||||||
if not file_path.endswith('.zip'):
|
if not ruta_archivo.lower().endswith('.zip'):
|
||||||
return {'detail': 'El archivo no es un .zip.'}
|
return {'detail': 'El archivo no es un .zip.'}
|
||||||
|
|
||||||
documentos_encontrados = []
|
# Descargar archivo
|
||||||
registros_cargados = {}
|
with tempfile.NamedTemporaryFile(delete=False, suffix='.zip') as tmp:
|
||||||
registros_por_archivo = {}
|
tmp_path = tmp.name
|
||||||
errores_por_archivo = {}
|
|
||||||
errores_pedimento = []
|
success = storage_service.download_file(ruta_archivo, tmp_path)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
print(f"No se pudo descargar: {ruta_archivo}")
|
||||||
|
return {'detail': f'No se pudo descargar el archivo: {ruta_archivo}'}
|
||||||
|
|
||||||
|
file_path = tmp_path
|
||||||
|
|
||||||
|
# Obtener organización
|
||||||
user_organizacion = None
|
user_organizacion = None
|
||||||
|
|
||||||
if user_organizacion_id:
|
if user_organizacion_id:
|
||||||
user_organizacion = Organizacion.objects.get(id=user_organizacion_id)
|
try:
|
||||||
|
user_organizacion = Organizacion.objects.get(id=user_organizacion_id)
|
||||||
|
except Organizacion.DoesNotExist:
|
||||||
|
print(f"Organización no encontrada: {user_organizacion_id}")
|
||||||
|
|
||||||
def to_snake_case(name):
|
# Leer ZIP y lanzar subtareas
|
||||||
s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
|
|
||||||
s2 = re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1)
|
|
||||||
return s2.replace('__', '_').lower()
|
|
||||||
|
|
||||||
# Lanzar una subtarea por cada archivo ASC
|
|
||||||
subtasks = []
|
subtasks = []
|
||||||
with zipfile.ZipFile(file_path, 'r') as zip_ref:
|
with zipfile.ZipFile(file_path, 'r') as zip_ref:
|
||||||
for asc_name in zip_ref.namelist():
|
namelist = zip_ref.namelist()
|
||||||
|
|
||||||
|
for asc_name in namelist:
|
||||||
if asc_name.endswith('.asc'):
|
if asc_name.endswith('.asc'):
|
||||||
subtasks.append(procesar_archivo_asc_task.s(datastage_id, user_organizacion_id, asc_name))
|
subtasks.append(
|
||||||
|
procesar_archivo_asc_task.s(datastage_id, user_organizacion_id, asc_name)
|
||||||
|
)
|
||||||
|
|
||||||
if subtasks:
|
if subtasks:
|
||||||
job = group(subtasks).apply_async()
|
job = group(subtasks).apply_async()
|
||||||
|
print(f"Grupo de tareas lanzado: {job.id}")
|
||||||
return {
|
return {
|
||||||
'group_id': job.id,
|
'group_id': job.id,
|
||||||
'subtask_ids': [t.id for t in job.results],
|
'subtask_ids': [t.id for t in job.results],
|
||||||
'detail': 'Procesamiento lanzado. Monitorea el estado de cada subtask_id.'
|
'detail': f'Procesamiento lanzado. {len(subtasks)} archivos .ASC en cola.'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
print("No se encontraron archivos .ASC")
|
||||||
return {'detail': 'No se encontraron archivos .asc'}
|
return {'detail': 'No se encontraron archivos .asc'}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
import traceback
|
||||||
return {'error': str(e), 'traceback': traceback.format_exc()}
|
return {'error': str(e), 'traceback': traceback.format_exc()}
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Limpiar temporal
|
||||||
|
if tmp_path and os.path.exists(tmp_path):
|
||||||
|
try:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"No se pudo eliminar temporal: {e}")
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name):
|
def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name):
|
||||||
import traceback
|
"""
|
||||||
|
Procesa un archivo .ASC individual dentro del ZIP
|
||||||
|
"""
|
||||||
|
tmp_path = None
|
||||||
try:
|
try:
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
from api.datastage.models import DataStage
|
from api.datastage.models import DataStage
|
||||||
from api.organization.models import Organizacion
|
from api.organization.models import Organizacion
|
||||||
from api.customs.models import Pedimento, TipoOperacion, Regimen
|
from api.customs.models import Pedimento, TipoOperacion, Regimen
|
||||||
from django.apps import apps
|
import datetime
|
||||||
import zipfile
|
|
||||||
import re
|
# Obtener datastage
|
||||||
datastage = DataStage.objects.get(id=datastage_id)
|
datastage = DataStage.objects.get(id=datastage_id)
|
||||||
user_organizacion = None
|
user_organizacion = None
|
||||||
if user_organizacion_id:
|
if user_organizacion_id:
|
||||||
user_organizacion = Organizacion.objects.get(id=user_organizacion_id)
|
user_organizacion = Organizacion.objects.get(id=user_organizacion_id)
|
||||||
file_path = datastage.archivo.path
|
|
||||||
|
ruta_archivo = str(datastage.archivo)
|
||||||
|
|
||||||
|
# Descargar archivo
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix='.zip') as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
|
||||||
|
success = storage_service.download_file(ruta_archivo, tmp_path)
|
||||||
|
if not success:
|
||||||
|
return {'errores': [f'No se pudo descargar el archivo: {ruta_archivo}']}
|
||||||
|
|
||||||
|
file_path = tmp_path
|
||||||
|
|
||||||
def to_snake_case(name):
|
def to_snake_case(name):
|
||||||
s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
|
s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
|
||||||
s2 = re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1)
|
s2 = re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1)
|
||||||
return s2.replace('__', '_').lower()
|
return s2.replace('__', '_').lower()
|
||||||
|
|
||||||
|
objects_to_create = []
|
||||||
|
|
||||||
with zipfile.ZipFile(file_path, 'r') as zip_ref:
|
with zipfile.ZipFile(file_path, 'r') as zip_ref:
|
||||||
if asc_name not in zip_ref.namelist():
|
if asc_name not in zip_ref.namelist():
|
||||||
|
print(f"❌ {asc_name} no encontrado en el ZIP")
|
||||||
return {'errores': [f'{asc_name} no encontrado en el zip']}
|
return {'errores': [f'{asc_name} no encontrado en el zip']}
|
||||||
|
|
||||||
|
# Determinar modelo
|
||||||
match = re.match(r'.*_(\d+)\.asc$', asc_name)
|
match = re.match(r'.*_(\d+)\.asc$', asc_name)
|
||||||
if match:
|
if match:
|
||||||
registro_key = match.group(1)
|
registro_key = match.group(1)
|
||||||
@@ -96,71 +148,86 @@ def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name):
|
|||||||
Model = apps.get_model('datastage', model_name)
|
Model = apps.get_model('datastage', model_name)
|
||||||
except LookupError:
|
except LookupError:
|
||||||
return {'errores': [f"No existe el modelo para {model_name}"]}
|
return {'errores': [f"No existe el modelo para {model_name}"]}
|
||||||
|
|
||||||
|
# Procesar archivo
|
||||||
with zip_ref.open(asc_name) as asc_file:
|
with zip_ref.open(asc_name) as asc_file:
|
||||||
first = True
|
first = True
|
||||||
field_names = []
|
|
||||||
field_names_snake = []
|
field_names_snake = []
|
||||||
objects_to_create = []
|
line_count = 0
|
||||||
errores_pedimento = []
|
|
||||||
for line in asc_file:
|
for line in asc_file:
|
||||||
line_decoded = None
|
line_count += 1
|
||||||
try:
|
try:
|
||||||
line_decoded = line.decode('utf-8').strip()
|
line_decoded = line.decode('utf-8').strip()
|
||||||
except UnicodeDecodeError:
|
except UnicodeDecodeError:
|
||||||
try:
|
try:
|
||||||
line_decoded = line.decode('latin-1').strip()
|
line_decoded = line.decode('latin-1').strip()
|
||||||
except Exception as e:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
except Exception as e:
|
|
||||||
continue
|
|
||||||
if not line_decoded:
|
if not line_decoded:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if first:
|
if first:
|
||||||
field_names = [f for f in line_decoded.split('|')]
|
field_names = line_decoded.split('|')
|
||||||
|
# Eliminar columnas vacías del final (líneas terminan con |)
|
||||||
|
while field_names and field_names[-1] == '':
|
||||||
|
field_names.pop()
|
||||||
field_names_snake = [to_snake_case(f) for f in field_names]
|
field_names_snake = [to_snake_case(f) for f in field_names]
|
||||||
first = False
|
first = False
|
||||||
continue
|
continue
|
||||||
|
|
||||||
values = line_decoded.split('|')
|
values = line_decoded.split('|')
|
||||||
while values and values[-1] == '':
|
while values and values[-1] == '':
|
||||||
values.pop()
|
values.pop()
|
||||||
if len(values) == len(field_names_snake) + 1 and values[-1] == '':
|
|
||||||
values = values[:-1]
|
|
||||||
if len(values) < len(field_names_snake):
|
|
||||||
values += [None] * (len(field_names_snake) - len(values))
|
|
||||||
if len(values) != len(field_names_snake):
|
if len(values) != len(field_names_snake):
|
||||||
|
logger.debug(
|
||||||
|
"%s línea %d: esperados %d campos, recibidos %d — se omite",
|
||||||
|
asc_name, line_count, len(field_names_snake), len(values)
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
data = dict(zip(field_names_snake, values))
|
data = dict(zip(field_names_snake, values))
|
||||||
|
|
||||||
if hasattr(Model, 'organizacion_id'):
|
if hasattr(Model, 'organizacion_id'):
|
||||||
data['organizacion_id'] = user_organizacion.id if user_organizacion else None
|
data['organizacion_id'] = user_organizacion.id if user_organizacion else None
|
||||||
if hasattr(Model, 'datastage_id'):
|
if hasattr(Model, 'datastage_id'):
|
||||||
data['datastage_id'] = datastage.id
|
data['datastage_id'] = datastage.id
|
||||||
# Limpiar campos de fecha vacíos ('') a None
|
|
||||||
|
# Parsear y normalizar todos los campos de fecha/datetime
|
||||||
for field in Model._meta.get_fields():
|
for field in Model._meta.get_fields():
|
||||||
if hasattr(field, 'get_internal_type') and field.get_internal_type() in ["DateField", "DateTimeField"]:
|
if not hasattr(field, 'get_internal_type'):
|
||||||
if data.get(field.name) == "":
|
continue
|
||||||
data[field.name] = None
|
field_type = field.get_internal_type()
|
||||||
# Convertir fecha_pago_real a timezone-aware si existe
|
val = data.get(field.name)
|
||||||
if 'fecha_pago_real' in data and data['fecha_pago_real']:
|
if val == '' or val is None:
|
||||||
from django.utils import timezone
|
data[field.name] = None
|
||||||
import datetime
|
continue
|
||||||
fecha_val = data['fecha_pago_real']
|
if field_type == 'DateTimeField' and isinstance(val, str):
|
||||||
if isinstance(fecha_val, str):
|
dt = None
|
||||||
try:
|
for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%d'):
|
||||||
dt = datetime.datetime.strptime(fecha_val, '%Y-%m-%d %H:%M:%S')
|
|
||||||
except ValueError:
|
|
||||||
try:
|
try:
|
||||||
dt = datetime.datetime.strptime(fecha_val, '%Y-%m-%d')
|
dt = datetime.datetime.strptime(val, fmt)
|
||||||
except Exception:
|
break
|
||||||
dt = None
|
except ValueError:
|
||||||
|
continue
|
||||||
if dt and timezone.is_naive(dt):
|
if dt and timezone.is_naive(dt):
|
||||||
dt = timezone.make_aware(dt)
|
dt = timezone.make_aware(dt)
|
||||||
if dt:
|
data[field.name] = dt
|
||||||
data['fecha_pago_real'] = dt
|
|
||||||
elif isinstance(fecha_val, datetime.datetime) and timezone.is_naive(fecha_val):
|
# Filtrar data para solo incluir campos válidos del modelo
|
||||||
data['fecha_pago_real'] = timezone.make_aware(fecha_val)
|
valid_fields = set()
|
||||||
|
for f in Model._meta.get_fields():
|
||||||
|
if hasattr(f, 'name'):
|
||||||
|
valid_fields.add(f.name)
|
||||||
|
if hasattr(f, 'attname'):
|
||||||
|
valid_fields.add(f.attname)
|
||||||
|
data = {k: v for k, v in data.items() if k in valid_fields}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
obj = Model(**data)
|
obj = Model(**data)
|
||||||
objects_to_create.append(obj)
|
objects_to_create.append(obj)
|
||||||
|
|
||||||
# Si es Registro501, crear Pedimento
|
# Si es Registro501, crear Pedimento
|
||||||
if model_name == 'Registro501':
|
if model_name == 'Registro501':
|
||||||
organizacion_instance = None
|
organizacion_instance = None
|
||||||
@@ -169,7 +236,7 @@ def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name):
|
|||||||
try:
|
try:
|
||||||
organizacion_instance = Organizacion.objects.get(id=org_id)
|
organizacion_instance = Organizacion.objects.get(id=org_id)
|
||||||
except Exception as org_exc:
|
except Exception as org_exc:
|
||||||
logger.warning(f"No se encontró la organización con id {org_id}: {org_exc}")
|
print(f"No se encontró la organización con id {org_id}: {org_exc}")
|
||||||
if not organizacion_instance:
|
if not organizacion_instance:
|
||||||
organizacion_instance = user_organizacion
|
organizacion_instance = user_organizacion
|
||||||
fecha_pago_raw = data.get('fecha_pago_real')
|
fecha_pago_raw = data.get('fecha_pago_real')
|
||||||
@@ -182,6 +249,7 @@ def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name):
|
|||||||
else:
|
else:
|
||||||
fecha_pago = fecha_pago_raw
|
fecha_pago = fecha_pago_raw
|
||||||
aduana = data.get('seccion_aduanera')
|
aduana = data.get('seccion_aduanera')
|
||||||
|
# logger.info(f"aduana >>>> {aduana}")
|
||||||
patente = data.get('patente')
|
patente = data.get('patente')
|
||||||
pedimento_num = data.get('pedimento')
|
pedimento_num = data.get('pedimento')
|
||||||
pedimento_app = ""
|
pedimento_app = ""
|
||||||
@@ -191,9 +259,13 @@ def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name):
|
|||||||
year = fecha_pago[:4]
|
year = fecha_pago[:4]
|
||||||
else:
|
else:
|
||||||
year = str(fecha_pago.year)
|
year = str(fecha_pago.year)
|
||||||
pedimento_app = f"{year[-2:]}-{str(aduana).zfill(2)[-2:]}-{str(patente).zfill(4)[-4:]}-{str(pedimento_num).zfill(7)[-7:]}"
|
# mantener aduana con sus digitos intactos
|
||||||
|
# pedimento_app = f"{year[-2:]}-{str(aduana).zfill(2)[-2:]}-{str(patente).zfill(4)[-4:]}-{str(pedimento_num).zfill(7)[-7:]}"
|
||||||
|
# pedimento_app = f"{year[-2:]}-{str(aduana)}-{str(patente).zfill(4)[-4:]}-{str(pedimento_num).zfill(7)[-7:]}"
|
||||||
|
pedimento_app = f"{year[-2:]}-{str(aduana).zfill(2)[:2]}-{str(patente).zfill(4)[-4:]}-{str(pedimento_num).zfill(7)[-7:]}"
|
||||||
|
# logger.info(f"pedimento_app >>>> {pedimento_app}")
|
||||||
except Exception as ped_app_exc:
|
except Exception as ped_app_exc:
|
||||||
logger.warning(f"No se pudo generar pedimento_app: {ped_app_exc}")
|
print(f"No se pudo generar pedimento_app: {ped_app_exc}")
|
||||||
tipo_operacion_val = data.get('tipo_operacion')
|
tipo_operacion_val = data.get('tipo_operacion')
|
||||||
tipo_operacion = TipoOperacion.objects.filter(id=int(tipo_operacion_val)).first() if tipo_operacion_val else None
|
tipo_operacion = TipoOperacion.objects.filter(id=int(tipo_operacion_val)).first() if tipo_operacion_val else None
|
||||||
regimen = Regimen.objects.filter(claveped=data.get('clave_documento', '').strip(), tipo=tipo_operacion.id if tipo_operacion else None).first() if tipo_operacion else None
|
regimen = Regimen.objects.filter(claveped=data.get('clave_documento', '').strip(), tipo=tipo_operacion.id if tipo_operacion else None).first() if tipo_operacion else None
|
||||||
@@ -225,18 +297,23 @@ def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name):
|
|||||||
"importe_pedimento": data.get('importe_pedimento', 0.0),
|
"importe_pedimento": data.get('importe_pedimento', 0.0),
|
||||||
"existe_expediente": data.get('existe_expediente', False),
|
"existe_expediente": data.get('existe_expediente', False),
|
||||||
"remesas": data.get('remesas', False),
|
"remesas": data.get('remesas', False),
|
||||||
|
"consultar_vucem": True,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
Pedimento.objects.create(**pedimento_data)
|
Pedimento.objects.create(**pedimento_data)
|
||||||
except Exception as ped_exc:
|
except Exception as ped_exc:
|
||||||
pass
|
logger.warning("No se pudo crear Pedimento %s: %s", pedimento_app, ped_exc)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
logger.error("%s línea %d: error creando objeto %s: %s", asc_name, line_count, model_name, e)
|
||||||
continue
|
continue
|
||||||
if objects_to_create:
|
|
||||||
try:
|
# Bulk create
|
||||||
Model.objects.bulk_create(objects_to_create, batch_size=1000)
|
if objects_to_create:
|
||||||
except Exception as e:
|
try:
|
||||||
return {'archivo': asc_name, 'error': str(e), 'traceback': traceback.format_exc()}
|
Model.objects.bulk_create(objects_to_create, batch_size=1000)
|
||||||
|
except Exception as e:
|
||||||
|
return {'archivo': asc_name, 'error': str(e)}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'archivo': asc_name,
|
'archivo': asc_name,
|
||||||
'insertados': len(objects_to_create)
|
'insertados': len(objects_to_create)
|
||||||
@@ -245,32 +322,10 @@ def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name):
|
|||||||
import traceback
|
import traceback
|
||||||
return {'archivo': asc_name, 'error': str(e), 'traceback': traceback.format_exc()}
|
return {'archivo': asc_name, 'error': str(e), 'traceback': traceback.format_exc()}
|
||||||
|
|
||||||
detalles = {}
|
finally:
|
||||||
for key in ['502', '503', '504']:
|
# Limpiar temporal
|
||||||
model_name = f'Registro{key}'
|
if tmp_path and os.path.exists(tmp_path):
|
||||||
asc_file = None
|
|
||||||
encabezado = None
|
|
||||||
errores = []
|
|
||||||
for asc_name in registros_por_archivo:
|
|
||||||
if asc_name.endswith(f'_{key}.asc'):
|
|
||||||
asc_file = asc_name
|
|
||||||
break
|
|
||||||
if asc_file:
|
|
||||||
try:
|
try:
|
||||||
with zipfile.ZipFile(file_path, 'r') as zip_ref:
|
os.unlink(tmp_path)
|
||||||
with zip_ref.open(asc_file) as f:
|
|
||||||
for line in f:
|
|
||||||
try:
|
|
||||||
encabezado = line.decode('utf-8').strip()
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
encabezado = line.decode('latin-1').strip()
|
|
||||||
break
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
encabezado = f'Error leyendo encabezado: {e}'
|
print(f"No se pudo eliminar temporal: {e}")
|
||||||
errores = errores_por_archivo.get(asc_file, [])
|
|
||||||
detalles[model_name] = {
|
|
||||||
'archivo': asc_file,
|
|
||||||
'encabezado': encabezado,
|
|
||||||
'errores': errores
|
|
||||||
}
|
|
||||||
return {'registros_cargados': registros_cargados, 'errores_pedimento': errores_pedimento}
|
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import atexit
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from api.utils.storage_service import storage_service
|
||||||
|
from config import settings
|
||||||
from rest_framework.pagination import PageNumberPagination
|
from rest_framework.pagination import PageNumberPagination
|
||||||
from api.customs.models import Pedimento, TipoOperacion, Regimen
|
from api.customs.models import Pedimento, TipoOperacion, Regimen
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
@@ -7,135 +12,147 @@ from rest_framework.decorators import action
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from django.http import FileResponse, Http404
|
from django.http import FileResponse, Http404
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from .models import DataStage
|
from .models import DataStage
|
||||||
from .serializer import DataStageSerializer
|
from .serializer import DataStageSerializer
|
||||||
|
|
||||||
from api.logger.mixins import LoggingMixin
|
from api.logger.mixins import LoggingMixin
|
||||||
from mixins.filtrado_organizacion import OrganizacionFiltradaMixin
|
from core.permissions import get_org_context, is_internal_service_request, require_permission
|
||||||
from core.permissions import (
|
|
||||||
IsSameOrganization,
|
|
||||||
IsSameOrganizationDeveloper,
|
|
||||||
IsSameOrganizationAndAdmin,
|
|
||||||
IsSuperUser
|
|
||||||
)
|
|
||||||
# Create your views here.
|
# Create your views here.
|
||||||
class DataStagePagination(PageNumberPagination):
|
class DataStagePagination(PageNumberPagination):
|
||||||
page_size = 20 # Valor por defecto
|
page_size = 20 # Valor por defecto
|
||||||
page_size_query_param = 'page_size'
|
page_size_query_param = 'page_size'
|
||||||
max_page_size = 1000
|
max_page_size = 1000
|
||||||
|
|
||||||
class DataStageViewSet(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltradaMixin):
|
class DataStageViewSet(LoggingMixin, viewsets.ModelViewSet):
|
||||||
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
ViewSet for managing DataStage instances.
|
ViewSet for managing DataStage instances.
|
||||||
Provides CRUD operations for DataStage.
|
Provides CRUD operations for DataStage.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
serializer_class = DataStageSerializer
|
serializer_class = DataStageSerializer
|
||||||
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
|
|
||||||
model = DataStage
|
model = DataStage
|
||||||
my_tags = ['DataStage']
|
my_tags = ['DataStage']
|
||||||
pagination_class = DataStagePagination
|
pagination_class = DataStagePagination
|
||||||
|
|
||||||
|
def get_permissions(self):
|
||||||
|
perms = {
|
||||||
|
'list': 'datastage.view',
|
||||||
|
'retrieve': 'datastage.view',
|
||||||
|
'create': 'datastage.create',
|
||||||
|
'update': 'datastage.create',
|
||||||
|
'partial_update': 'datastage.create',
|
||||||
|
'destroy': 'datastage.delete',
|
||||||
|
'procesar': 'datastage.process',
|
||||||
|
'download_datastage': 'datastage.view',
|
||||||
|
'task_status': 'datastage.view',
|
||||||
|
}
|
||||||
|
codename = perms.get(self.action, 'datastage.view')
|
||||||
|
return [IsAuthenticated(), require_permission(codename)()]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
if self.request.user.is_superuser:
|
if is_internal_service_request(self.request):
|
||||||
return DataStage.objects.all().order_by('-created_at')
|
return DataStage.objects.all().order_by('-created_at')
|
||||||
|
org = get_org_context(self.request.user)
|
||||||
if self.request.user.groups.filter(name='developer').exists() or self.request.user.groups.filter(name='admin').exists() or self.request.user.groups.filter(name='Agente Aduanal').exists():
|
if not org:
|
||||||
return DataStage.objects.filter(organizacion=self.request.user.organizacion).order_by('-created_at')
|
return DataStage.objects.none()
|
||||||
|
return DataStage.objects.filter(organizacion=org).order_by('-created_at')
|
||||||
return self.get_queryset_filtrado_por_organizacion().order_by('-created_at')
|
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
"""
|
org = get_org_context(self.request.user)
|
||||||
Permite que la organización sea opcional en el request, pero si no se envía, se asigna la del usuario autenticado.
|
datastage = serializer.save(organizacion=org)
|
||||||
"""
|
self._trigger_processing(datastage)
|
||||||
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'):
|
|
||||||
raise ValueError("Usuario no autenticado o sin organización")
|
|
||||||
|
|
||||||
data = serializer.validated_data
|
|
||||||
organizacion = data.get('organizacion')
|
|
||||||
|
|
||||||
if self.request.user.is_superuser:
|
|
||||||
# Permitir que el superusuario cree sin organización o la especifique
|
|
||||||
datastage = serializer.save()
|
|
||||||
self._trigger_processing(datastage)
|
|
||||||
return
|
|
||||||
|
|
||||||
if (self.request.user.groups.filter(name='developer').exists() or self.request.user.groups.filter(name='admin').exists() or self.request.user.groups.filter(name='user').exists()) and self.request.user.groups.filter(name='Agente Aduanal').exists():
|
|
||||||
if not organizacion:
|
|
||||||
datastage = serializer.save(organizacion=self.request.user.organizacion)
|
|
||||||
else:
|
|
||||||
datastage = serializer.save()
|
|
||||||
|
|
||||||
self._trigger_processing(datastage)
|
|
||||||
|
|
||||||
return
|
|
||||||
|
|
||||||
raise ValueError("No cuentas con los permisos necesarios para crear un DataStage")
|
|
||||||
|
|
||||||
def _trigger_processing(self, datastage):
|
def _trigger_processing(self, datastage):
|
||||||
"""
|
|
||||||
Método helper para disparar el procesamiento.
|
|
||||||
"""
|
|
||||||
from api.datastage.tasks import procesar_datastage_task
|
from api.datastage.tasks import procesar_datastage_task
|
||||||
user_organizacion = getattr(self.request.user, 'organizacion', None)
|
org = get_org_context(self.request.user)
|
||||||
user_organizacion_id = user_organizacion.id if user_organizacion else None
|
|
||||||
|
|
||||||
datastage.procesado = True
|
datastage.procesado = True
|
||||||
datastage.save()
|
datastage.save()
|
||||||
|
procesar_datastage_task.delay(datastage.id, org.id if org else None)
|
||||||
task = procesar_datastage_task.delay(datastage.id, user_organizacion_id)
|
|
||||||
|
|
||||||
def perform_update(self, serializer):
|
def perform_update(self, serializer):
|
||||||
"""
|
if is_internal_service_request(self.request):
|
||||||
Override to ensure organization is set on update.
|
|
||||||
"""
|
|
||||||
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'):
|
|
||||||
raise ValueError("Usuario no autenticado o sin organización")
|
|
||||||
|
|
||||||
if self.request.user.is_superuser:
|
|
||||||
# Allow superuser to update without organization
|
|
||||||
serializer.save()
|
serializer.save()
|
||||||
return
|
return
|
||||||
|
org = get_org_context(self.request.user)
|
||||||
|
serializer.save(organizacion=org)
|
||||||
|
|
||||||
if (self.request.user.groups.filter(name='developer').exists() or self.request.user.groups.filter(name='admin').exists() or self.request.user.groups.filter(name='user').exists()) and self.request.user.groups.filter(name='Agente Aduanal').exists():
|
def perform_destroy(self, instance):
|
||||||
serializer.save(organizacion=self.request.user.organizacion)
|
if instance.archivo:
|
||||||
return
|
storage_service.delete_file(instance.archivo)
|
||||||
|
instance.delete()
|
||||||
raise ValueError("No cuentas con los permisos necesarios para actualizar un DataStage")
|
|
||||||
|
|
||||||
@action(detail=True, methods=['get'], url_path='download-datastage', url_name='download-datastage')
|
@action(detail=True, methods=['get'], url_path='download-datastage', url_name='download-datastage')
|
||||||
def download_datastage(self, request, pk=None):
|
def download_datastage(self, request, pk=None):
|
||||||
"""
|
"""
|
||||||
Endpoint para descargar el archivo asociado a un DataStage.
|
Endpoint para descargar el archivo asociado a un DataStage.
|
||||||
|
Soporta tanto archivos en MinIO como archivos locales antiguos.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
datastage = self.get_object()
|
datastage = self.get_object()
|
||||||
if not datastage.archivo:
|
if not datastage.archivo:
|
||||||
raise Http404("No hay archivo asociado a este DataStage.")
|
raise Http404("No hay archivo asociado a este DataStage.")
|
||||||
file_path = datastage.archivo.path
|
|
||||||
if not os.path.exists(file_path):
|
# Detectar si es ruta de MinIO o local
|
||||||
raise Http404("El archivo no existe en el servidor.")
|
is_minio_path = datastage.archivo.startswith('org_')
|
||||||
response = FileResponse(open(file_path, 'rb'), as_attachment=True, filename=os.path.basename(file_path))
|
|
||||||
return response
|
if is_minio_path:
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False) as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
|
||||||
|
success = storage_service.download_file(datastage.archivo, tmp_path)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
raise Http404("No se pudo descargar el archivo de MinIO")
|
||||||
|
|
||||||
|
filename = os.path.basename(datastage.archivo)
|
||||||
|
|
||||||
|
response = FileResponse(
|
||||||
|
open(tmp_path, 'rb'),
|
||||||
|
as_attachment=True,
|
||||||
|
filename=filename
|
||||||
|
)
|
||||||
|
|
||||||
|
import atexit
|
||||||
|
atexit.register(lambda: os.unlink(tmp_path) if os.path.exists(tmp_path) else None)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
else:
|
||||||
|
file_path = os.path.join(settings.MEDIA_ROOT, str(datastage.archivo))
|
||||||
|
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
raise Http404(f"El archivo no existe: {file_path}")
|
||||||
|
|
||||||
|
filename = os.path.basename(file_path)
|
||||||
|
|
||||||
|
response = FileResponse(
|
||||||
|
open(file_path, 'rb'),
|
||||||
|
as_attachment=True,
|
||||||
|
filename=filename
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return Response({'detail': str(e)}, status=404)
|
return Response({'detail': str(e)}, status=404)
|
||||||
|
|
||||||
|
def perform_destroy(self, instance):
|
||||||
|
"""
|
||||||
|
Al eliminar un DataStage, también eliminar su archivo asociado.
|
||||||
|
"""
|
||||||
|
if instance.archivo:
|
||||||
|
storage_service.delete_file(instance.archivo)
|
||||||
|
instance.delete()
|
||||||
|
|
||||||
@action(detail=True, methods=['post'], url_path='procesar')
|
@action(detail=True, methods=['post'], url_path='procesar')
|
||||||
def procesar(self, request, pk=None):
|
def procesar(self, request, pk=None):
|
||||||
"""
|
"""
|
||||||
Endpoint para procesar el DataStage de forma asíncrona usando Celery.
|
Endpoint para procesar el DataStage de forma asíncrona usando Celery.
|
||||||
"""
|
"""
|
||||||
# ojo aqui
|
|
||||||
from api.datastage.tasks import procesar_datastage_task
|
from api.datastage.tasks import procesar_datastage_task
|
||||||
datastage = self.get_object()
|
datastage = self.get_object()
|
||||||
user_organizacion = getattr(self.request.user, 'organizacion', None)
|
org = get_org_context(self.request.user)
|
||||||
user_organizacion_id = user_organizacion.id if user_organizacion else None
|
task = procesar_datastage_task.delay(datastage.id, org.id if org else None)
|
||||||
task = procesar_datastage_task.delay(datastage.id, user_organizacion_id)
|
|
||||||
return Response({
|
return Response({
|
||||||
'task_id': task.id,
|
'task_id': task.id,
|
||||||
'detail': 'Procesamiento iniciado. Puede consultar el estado con el task_id.'
|
'detail': 'Procesamiento iniciado. Puede consultar el estado con el task_id.'
|
||||||
|
|||||||
@@ -58,8 +58,7 @@ class UserActivityViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
if not self.request.user.is_authenticated:
|
if not self.request.user.is_authenticated:
|
||||||
return UserActivity.objects.none()
|
return UserActivity.objects.none()
|
||||||
|
|
||||||
# Los usuarios normales solo ven su propia actividad
|
if self.request.user.is_superuser:
|
||||||
if self.request.user.is_staff:
|
|
||||||
return UserActivity.objects.all()
|
return UserActivity.objects.all()
|
||||||
return UserActivity.objects.filter(user=self.request.user)
|
return UserActivity.objects.filter(user=self.request.user)
|
||||||
|
|
||||||
|
|||||||
0
api/management/__init__.py
Normal file
0
api/management/__init__.py
Normal file
0
api/management/commands/__init__.py
Normal file
0
api/management/commands/__init__.py
Normal file
472
api/management/commands/migrate_to_minio.py
Normal file
472
api/management/commands/migrate_to_minio.py
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
import os
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from minio import Minio
|
||||||
|
|
||||||
|
from api.record.models import Document
|
||||||
|
from api.datastage.models import DataStage
|
||||||
|
from api.vucem.models import Vucem
|
||||||
|
from api.reports.models import ReportDocument
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Migra archivos existentes del sistema local a MinIO (versión optimizada)'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument('--dry-run', action='store_true', help='Solo muestra lo que se migraría')
|
||||||
|
parser.add_argument('--model', type=str, help='Document, DataStage, Vucem, ReportDocument')
|
||||||
|
parser.add_argument('--limit', type=int, help='Límite de registros')
|
||||||
|
parser.add_argument('--batch-size', type=int, default=200, help='Tamaño del lote (default: 200)')
|
||||||
|
parser.add_argument('--workers', type=int, default=3, help='Número de workers (default: 3)')
|
||||||
|
parser.add_argument('--offset', type=int, default=0, help='Offset inicial (para reanudar)')
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.client = None
|
||||||
|
self.bucket_name = None
|
||||||
|
|
||||||
|
def _init_minio_client(self):
|
||||||
|
"""Inicializa el cliente MinIO"""
|
||||||
|
if self.client is None:
|
||||||
|
self.client = Minio(
|
||||||
|
endpoint=os.getenv('MINIO_ENDPOINT', 'minio:9000'),
|
||||||
|
access_key=os.getenv('MINIO_ACCESS_KEY'),
|
||||||
|
secret_key=os.getenv('MINIO_SECRET_KEY'),
|
||||||
|
secure=os.getenv('MINIO_SECURE', 'false').lower() == 'true'
|
||||||
|
)
|
||||||
|
self.bucket_name = os.getenv('MINIO_BUCKET_NAME', 'efc-backend-dev')
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
dry_run = options.get('dry_run', False)
|
||||||
|
model_filter = options.get('model')
|
||||||
|
limit = options.get('limit')
|
||||||
|
batch_size = options.get('batch_size', 200)
|
||||||
|
workers = options.get('workers', 3)
|
||||||
|
offset = options.get('offset', 0)
|
||||||
|
|
||||||
|
self.stdout.write(self.style.WARNING('=' * 60))
|
||||||
|
self.stdout.write(self.style.WARNING('INICIANDO MIGRACIÓN A MINIO (OPTIMIZADA)'))
|
||||||
|
self.stdout.write(self.style.WARNING(f'Batch size: {batch_size} | Workers: {workers} | Offset: {offset}'))
|
||||||
|
if dry_run:
|
||||||
|
self.stdout.write(self.style.WARNING('MODO: DRY RUN (sin cambios)'))
|
||||||
|
self.stdout.write(self.style.WARNING('=' * 60))
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
if not model_filter or model_filter.lower() == 'document':
|
||||||
|
results['Document'] = self.migrate_documents(dry_run, limit, batch_size, workers, offset)
|
||||||
|
|
||||||
|
if not model_filter or model_filter.lower() == 'datastage':
|
||||||
|
results['DataStage'] = self.migrate_datastage(dry_run, limit, batch_size, workers, offset)
|
||||||
|
|
||||||
|
if not model_filter or model_filter.lower() == 'vucem':
|
||||||
|
results['Vucem'] = self.migrate_vucem(dry_run, limit, workers)
|
||||||
|
|
||||||
|
if not model_filter or model_filter.lower() == 'reportdocument':
|
||||||
|
results['ReportDocument'] = self.migrate_reports(dry_run, limit, batch_size, workers, offset)
|
||||||
|
|
||||||
|
# Resumen final
|
||||||
|
self.stdout.write('\n' + '=' * 60)
|
||||||
|
self.stdout.write(self.style.SUCCESS('RESUMEN DE MIGRACIÓN'))
|
||||||
|
self.stdout.write('=' * 60)
|
||||||
|
|
||||||
|
total_migrados = 0
|
||||||
|
total_no_encontrados = 0
|
||||||
|
total_errores = 0
|
||||||
|
|
||||||
|
for model_name, stats in results.items():
|
||||||
|
self.stdout.write(f"\n📁 {model_name}:")
|
||||||
|
self.stdout.write(f" ✅ Migrados: {stats['migrated']}")
|
||||||
|
self.stdout.write(f" ⚠️ No encontrados: {stats['not_found']}")
|
||||||
|
self.stdout.write(f" ❌ Errores: {stats['errors']}")
|
||||||
|
total_migrados += stats['migrated']
|
||||||
|
total_no_encontrados += stats['not_found']
|
||||||
|
total_errores += stats['errors']
|
||||||
|
|
||||||
|
self.stdout.write('\n' + '-' * 40)
|
||||||
|
self.stdout.write(f"📊 TOTAL Migrados: {total_migrados}")
|
||||||
|
self.stdout.write(f"📊 TOTAL No encontrados: {total_no_encontrados}")
|
||||||
|
self.stdout.write(f"📊 TOTAL Errores: {total_errores}")
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
self.stdout.write('\n' + self.style.WARNING('⚠️ MODO DRY RUN - No se realizaron cambios'))
|
||||||
|
|
||||||
|
def get_local_file_path(self, path_str):
|
||||||
|
"""Obtiene la ruta completa del archivo local"""
|
||||||
|
return Path(settings.MEDIA_ROOT) / path_str
|
||||||
|
|
||||||
|
def migrate_documents(self, dry_run, limit, batch_size, workers, offset):
|
||||||
|
"""Migra documentos del modelo Document"""
|
||||||
|
self._init_minio_client()
|
||||||
|
stats = {'migrated': 0, 'not_found': 0, 'errors': 0}
|
||||||
|
|
||||||
|
queryset = Document.objects.exclude(archivo='').exclude(archivo__isnull=True)
|
||||||
|
queryset = queryset.exclude(archivo__startswith='org_')
|
||||||
|
queryset = queryset.order_by('created_at')
|
||||||
|
|
||||||
|
if offset:
|
||||||
|
queryset = queryset[offset:]
|
||||||
|
|
||||||
|
if limit:
|
||||||
|
queryset = queryset[:limit]
|
||||||
|
|
||||||
|
total = queryset.count()
|
||||||
|
self.stdout.write(f"\n📄 Procesando {total} documentos...")
|
||||||
|
|
||||||
|
if total == 0:
|
||||||
|
return stats
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
processed = 0
|
||||||
|
|
||||||
|
# Procesar en lotes
|
||||||
|
for batch_start in range(0, total, batch_size):
|
||||||
|
batch = queryset[batch_start:batch_start + batch_size]
|
||||||
|
batch_docs = list(batch)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
stats['migrated'] += len(batch_docs)
|
||||||
|
processed += len(batch_docs)
|
||||||
|
self._print_progress(processed, total, start_time, stats)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Preparar items para workers
|
||||||
|
items = []
|
||||||
|
for doc in batch_docs:
|
||||||
|
path_str = str(doc.archivo)
|
||||||
|
local_path = self.get_local_file_path(path_str)
|
||||||
|
|
||||||
|
if not local_path.exists():
|
||||||
|
stats['not_found'] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
pedimento_app = doc.pedimento.pedimento_app if doc.pedimento else 'unknown'
|
||||||
|
items.append({
|
||||||
|
'doc': doc,
|
||||||
|
'local_path': local_path,
|
||||||
|
'path_str': path_str,
|
||||||
|
'pedimento_app': pedimento_app
|
||||||
|
})
|
||||||
|
|
||||||
|
# Procesar en paralelo
|
||||||
|
if items:
|
||||||
|
with ThreadPoolExecutor(max_workers=workers) as executor:
|
||||||
|
futures = {executor.submit(self._upload_document, item): item for item in items}
|
||||||
|
|
||||||
|
for future in as_completed(futures):
|
||||||
|
result = future.result()
|
||||||
|
if result['success']:
|
||||||
|
stats['migrated'] += 1
|
||||||
|
else:
|
||||||
|
stats['errors'] += 1
|
||||||
|
|
||||||
|
processed += len(batch_docs)
|
||||||
|
self._print_progress(processed, total, start_time, stats)
|
||||||
|
|
||||||
|
total_time = time.time() - start_time
|
||||||
|
self.stdout.write(f"\n ✅ Completado en {total_time/60:.1f} minutos")
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
def _upload_document(self, item):
|
||||||
|
"""Sube un documento directamente a MinIO"""
|
||||||
|
try:
|
||||||
|
doc = item['doc']
|
||||||
|
local_path = item['local_path']
|
||||||
|
pedimento_app = item['pedimento_app']
|
||||||
|
filename = local_path.name
|
||||||
|
|
||||||
|
# Generar ruta MinIO
|
||||||
|
object_name = f"org_{doc.organizacion_id}/documents/{pedimento_app}/{filename}"
|
||||||
|
|
||||||
|
# Subir directamente a MinIO
|
||||||
|
self.client.fput_object(
|
||||||
|
bucket_name=self.bucket_name,
|
||||||
|
object_name=object_name,
|
||||||
|
file_path=str(local_path)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Actualizar base de datos
|
||||||
|
doc.archivo = object_name
|
||||||
|
doc.save(update_fields=['archivo'])
|
||||||
|
|
||||||
|
return {'success': True, 'doc_id': doc.id}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {'success': False, 'doc_id': doc.id, 'error': str(e)}
|
||||||
|
|
||||||
|
def migrate_datastage(self, dry_run, limit, batch_size, workers, offset):
|
||||||
|
"""Migra archivos del modelo DataStage"""
|
||||||
|
self._init_minio_client()
|
||||||
|
stats = {'migrated': 0, 'not_found': 0, 'errors': 0}
|
||||||
|
|
||||||
|
queryset = DataStage.objects.exclude(archivo='').exclude(archivo__isnull=True)
|
||||||
|
queryset = queryset.exclude(archivo__startswith='org_')
|
||||||
|
queryset = queryset.order_by('created_at')
|
||||||
|
|
||||||
|
if offset:
|
||||||
|
queryset = queryset[offset:]
|
||||||
|
|
||||||
|
if limit:
|
||||||
|
queryset = queryset[:limit]
|
||||||
|
|
||||||
|
total = queryset.count()
|
||||||
|
self.stdout.write(f"\n📦 Procesando {total} archivos DataStage...")
|
||||||
|
|
||||||
|
if total == 0:
|
||||||
|
return stats
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
processed = 0
|
||||||
|
|
||||||
|
for batch_start in range(0, total, batch_size):
|
||||||
|
batch = queryset[batch_start:batch_start + batch_size]
|
||||||
|
batch_docs = list(batch)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
stats['migrated'] += len(batch_docs)
|
||||||
|
processed += len(batch_docs)
|
||||||
|
self._print_progress(processed, total, start_time, stats)
|
||||||
|
continue
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for ds in batch_docs:
|
||||||
|
path_str = str(ds.archivo)
|
||||||
|
local_path = self.get_local_file_path(path_str)
|
||||||
|
|
||||||
|
if not local_path.exists():
|
||||||
|
stats['not_found'] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
items.append({'ds': ds, 'local_path': local_path})
|
||||||
|
|
||||||
|
if items:
|
||||||
|
with ThreadPoolExecutor(max_workers=workers) as executor:
|
||||||
|
futures = {executor.submit(self._upload_datastage, item): item for item in items}
|
||||||
|
|
||||||
|
for future in as_completed(futures):
|
||||||
|
result = future.result()
|
||||||
|
if result['success']:
|
||||||
|
stats['migrated'] += 1
|
||||||
|
else:
|
||||||
|
stats['errors'] += 1
|
||||||
|
|
||||||
|
processed += len(batch_docs)
|
||||||
|
self._print_progress(processed, total, start_time, stats)
|
||||||
|
|
||||||
|
total_time = time.time() - start_time
|
||||||
|
self.stdout.write(f"\n ✅ Completado en {total_time/60:.1f} minutos")
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
def _upload_datastage(self, item):
|
||||||
|
"""Sube un DataStage directamente a MinIO"""
|
||||||
|
try:
|
||||||
|
ds = item['ds']
|
||||||
|
local_path = item['local_path']
|
||||||
|
filename = local_path.name
|
||||||
|
|
||||||
|
object_name = f"org_{ds.organizacion_id}/datastage/{filename}"
|
||||||
|
|
||||||
|
self.client.fput_object(
|
||||||
|
bucket_name=self.bucket_name,
|
||||||
|
object_name=object_name,
|
||||||
|
file_path=str(local_path)
|
||||||
|
)
|
||||||
|
|
||||||
|
ds.archivo = object_name
|
||||||
|
ds.save(update_fields=['archivo'])
|
||||||
|
|
||||||
|
return {'success': True, 'id': ds.id}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {'success': False, 'id': ds.id, 'error': str(e)}
|
||||||
|
|
||||||
|
def migrate_vucem(self, dry_run, limit, workers):
|
||||||
|
"""Migra archivos key y cer del modelo Vucem"""
|
||||||
|
self._init_minio_client()
|
||||||
|
stats = {'migrated': 0, 'not_found': 0, 'errors': 0}
|
||||||
|
|
||||||
|
queryset = Vucem.objects.all()
|
||||||
|
if limit:
|
||||||
|
queryset = queryset[:limit]
|
||||||
|
|
||||||
|
total = queryset.count() * 2
|
||||||
|
self.stdout.write(f"\n🔐 Procesando {queryset.count()} registros VUCEM (key + cer)...")
|
||||||
|
|
||||||
|
if total == 0:
|
||||||
|
return stats
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for vucem in queryset:
|
||||||
|
if vucem.key and not str(vucem.key).startswith('org_'):
|
||||||
|
path_str = str(vucem.key)
|
||||||
|
local_path = self.get_local_file_path(path_str)
|
||||||
|
if local_path.exists():
|
||||||
|
items.append({'vucem': vucem, 'local_path': local_path, 'tipo': 'key'})
|
||||||
|
else:
|
||||||
|
stats['not_found'] += 1
|
||||||
|
|
||||||
|
if vucem.cer and not str(vucem.cer).startswith('org_'):
|
||||||
|
path_str = str(vucem.cer)
|
||||||
|
local_path = self.get_local_file_path(path_str)
|
||||||
|
if local_path.exists():
|
||||||
|
items.append({'vucem': vucem, 'local_path': local_path, 'tipo': 'cer'})
|
||||||
|
else:
|
||||||
|
stats['not_found'] += 1
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
stats['migrated'] = len(items)
|
||||||
|
self.stdout.write(f" 📝 [DRY RUN] Se migrarían {len(items)} archivos")
|
||||||
|
return stats
|
||||||
|
|
||||||
|
if items:
|
||||||
|
with ThreadPoolExecutor(max_workers=workers) as executor:
|
||||||
|
futures = {executor.submit(self._upload_vucem, item): item for item in items}
|
||||||
|
|
||||||
|
for future in as_completed(futures):
|
||||||
|
result = future.result()
|
||||||
|
if result['success']:
|
||||||
|
stats['migrated'] += 1
|
||||||
|
self.stdout.write(self.style.SUCCESS(f" ✅ {result['tipo']} migrado: {result['id']}"))
|
||||||
|
else:
|
||||||
|
stats['errors'] += 1
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
def _upload_vucem(self, item):
|
||||||
|
"""Sube un archivo VUCEM directamente a MinIO"""
|
||||||
|
try:
|
||||||
|
vucem = item['vucem']
|
||||||
|
local_path = item['local_path']
|
||||||
|
tipo = item['tipo']
|
||||||
|
filename = local_path.name
|
||||||
|
|
||||||
|
if tipo == 'key':
|
||||||
|
object_name = f"org_{vucem.organizacion_id}/vucem_keys/{filename}"
|
||||||
|
vucem.key = object_name
|
||||||
|
vucem.save(update_fields=['key'])
|
||||||
|
else:
|
||||||
|
object_name = f"org_{vucem.organizacion_id}/vucem_certs/{filename}"
|
||||||
|
vucem.cer = object_name
|
||||||
|
vucem.save(update_fields=['cer'])
|
||||||
|
|
||||||
|
self.client.fput_object(
|
||||||
|
bucket_name=self.bucket_name,
|
||||||
|
object_name=object_name,
|
||||||
|
file_path=str(local_path)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {'success': True, 'id': vucem.id, 'tipo': tipo}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {'success': False, 'id': vucem.id, 'tipo': tipo, 'error': str(e)}
|
||||||
|
|
||||||
|
def migrate_reports(self, dry_run, limit, batch_size, workers, offset):
|
||||||
|
"""Migra archivos del modelo ReportDocument"""
|
||||||
|
self._init_minio_client()
|
||||||
|
stats = {'migrated': 0, 'not_found': 0, 'errors': 0}
|
||||||
|
|
||||||
|
queryset = ReportDocument.objects.exclude(file='').exclude(file__isnull=True)
|
||||||
|
queryset = queryset.exclude(file__startswith='org_')
|
||||||
|
queryset = queryset.order_by('created_at')
|
||||||
|
|
||||||
|
if offset:
|
||||||
|
queryset = queryset[offset:]
|
||||||
|
|
||||||
|
if limit:
|
||||||
|
queryset = queryset[:limit]
|
||||||
|
|
||||||
|
total = queryset.count()
|
||||||
|
self.stdout.write(f"\n📊 Procesando {total} reportes...")
|
||||||
|
|
||||||
|
if total == 0:
|
||||||
|
return stats
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
processed = 0
|
||||||
|
|
||||||
|
for batch_start in range(0, total, batch_size):
|
||||||
|
batch = queryset[batch_start:batch_start + batch_size]
|
||||||
|
batch_docs = list(batch)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
stats['migrated'] += len(batch_docs)
|
||||||
|
processed += len(batch_docs)
|
||||||
|
self._print_progress(processed, total, start_time, stats)
|
||||||
|
continue
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for report in batch_docs:
|
||||||
|
path_str = str(report.file)
|
||||||
|
local_path = self.get_local_file_path(path_str)
|
||||||
|
|
||||||
|
if not local_path.exists():
|
||||||
|
stats['not_found'] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
items.append({'report': report, 'local_path': local_path})
|
||||||
|
|
||||||
|
if items:
|
||||||
|
with ThreadPoolExecutor(max_workers=workers) as executor:
|
||||||
|
futures = {executor.submit(self._upload_report, item): item for item in items}
|
||||||
|
|
||||||
|
for future in as_completed(futures):
|
||||||
|
result = future.result()
|
||||||
|
if result['success']:
|
||||||
|
stats['migrated'] += 1
|
||||||
|
else:
|
||||||
|
stats['errors'] += 1
|
||||||
|
|
||||||
|
processed += len(batch_docs)
|
||||||
|
self._print_progress(processed, total, start_time, stats)
|
||||||
|
|
||||||
|
total_time = time.time() - start_time
|
||||||
|
self.stdout.write(f"\n ✅ Completado en {total_time/60:.1f} minutos")
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
def _upload_report(self, item):
|
||||||
|
"""Sube un reporte directamente a MinIO"""
|
||||||
|
try:
|
||||||
|
report = item['report']
|
||||||
|
local_path = item['local_path']
|
||||||
|
filename = local_path.name
|
||||||
|
|
||||||
|
filters = report.filters or {}
|
||||||
|
org_id = filters.get('organizacion_id', 'unknown')
|
||||||
|
|
||||||
|
object_name = f"org_{org_id}/reports/{filename}"
|
||||||
|
|
||||||
|
self.client.fput_object(
|
||||||
|
bucket_name=self.bucket_name,
|
||||||
|
object_name=object_name,
|
||||||
|
file_path=str(local_path)
|
||||||
|
)
|
||||||
|
|
||||||
|
report.file = object_name
|
||||||
|
report.save(update_fields=['file'])
|
||||||
|
|
||||||
|
return {'success': True, 'id': report.id}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {'success': False, 'id': report.id, 'error': str(e)}
|
||||||
|
|
||||||
|
def _print_progress(self, processed, total, start_time, stats):
|
||||||
|
"""Imprime el progreso actual"""
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
rate = processed / elapsed if elapsed > 0 else 0
|
||||||
|
pct = processed * 100 / total if total > 0 else 0
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
f" 📊 {processed}/{total} ({pct:.1f}%) | "
|
||||||
|
f"{rate:.0f} docs/seg | "
|
||||||
|
f"✅ {stats['migrated']} | "
|
||||||
|
f"⚠️ {stats['not_found']} | "
|
||||||
|
f"❌ {stats['errors']}"
|
||||||
|
)
|
||||||
18
api/notificaciones/migrations/0002_notificacion_datos.py
Normal file
18
api/notificaciones/migrations/0002_notificacion_datos.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.3 on 2026-05-26 13:40
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('notificaciones', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='notificacion',
|
||||||
|
name='datos',
|
||||||
|
field=models.JSONField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -21,6 +21,7 @@ class Notificacion(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
mensaje = models.TextField(help_text="Mensaje de la notificación")
|
mensaje = models.TextField(help_text="Mensaje de la notificación")
|
||||||
|
datos = models.JSONField(null=True, blank=True)
|
||||||
fecha_envio = models.DateTimeField(blank=True, null=True, help_text="Fecha de envío de la notificación")
|
fecha_envio = models.DateTimeField(blank=True, null=True, help_text="Fecha de envío de la notificación")
|
||||||
created_at = models.DateTimeField(auto_now_add=True, help_text="Fecha de creación de la notificación")
|
created_at = models.DateTimeField(auto_now_add=True, help_text="Fecha de creación de la notificación")
|
||||||
visto = models.BooleanField(default=False, help_text="Indica si la notificación ha sido vista")
|
visto = models.BooleanField(default=False, help_text="Indica si la notificación ha sido vista")
|
||||||
|
|||||||
@@ -16,10 +16,11 @@ class NotificacionSerializer(serializers.ModelSerializer):
|
|||||||
'tipo',
|
'tipo',
|
||||||
'dirigido',
|
'dirigido',
|
||||||
'mensaje',
|
'mensaje',
|
||||||
|
'datos',
|
||||||
'fecha_envio',
|
'fecha_envio',
|
||||||
'created_at',
|
'created_at',
|
||||||
'visto'
|
'visto'
|
||||||
]
|
]
|
||||||
read_only_fields = ['id', 'created_at', 'tipo', 'dirigido', 'fecha_envio', 'mensaje']
|
read_only_fields = ['id', 'created_at', 'tipo', 'dirigido', 'fecha_envio', 'mensaje', 'datos']
|
||||||
|
|
||||||
|
|
||||||
@@ -4,31 +4,43 @@ from django.dispatch import receiver
|
|||||||
from api.notificaciones.models import Notificacion
|
from api.notificaciones.models import Notificacion
|
||||||
from api.record.models import Document
|
from api.record.models import Document
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=Document)
|
@receiver(post_save, sender=Document)
|
||||||
def trigger_notificacion(sender, instance, created, **kwargs):
|
def trigger_notificacion(sender, instance, created, **kwargs):
|
||||||
if created:
|
if not created:
|
||||||
from api.cuser.models import CustomUser
|
return
|
||||||
from api.customs.models import Pedimento
|
|
||||||
from api.notificaciones.models import TipoNotificacion
|
|
||||||
|
|
||||||
# Obtener el tipo de notificación (puedes ajustar el nombre si tienes tipos definidos)
|
from api.cuser.models import CustomUser
|
||||||
tipo_info, _ = TipoNotificacion.objects.get_or_create(tipo="info", defaults={"descripcion": "Notificación informativa"})
|
from api.notificaciones.models import TipoNotificacion
|
||||||
|
from core.permissions import user_has_permission
|
||||||
|
|
||||||
# Notificar a todos los usuarios de la organización
|
tipo_info, _ = TipoNotificacion.objects.get_or_create(
|
||||||
usuarios_org = CustomUser.objects.filter(organizacion=instance.organizacion)
|
tipo='info',
|
||||||
for usuario in usuarios_org:
|
defaults={'descripcion': 'Notificación informativa'},
|
||||||
# Notificar solo a importadores cuyo RFC coincide
|
)
|
||||||
if (usuario.is_importador or usuario.groups.filter(name='Importador').exists()):
|
|
||||||
if usuario.rfc == instance.pedimento.contribuyente:
|
mensaje = (
|
||||||
Notificacion.objects.create(
|
f"Se agregó el documento {instance.archivo} "
|
||||||
tipo=tipo_info,
|
f"al pedimento {instance.pedimento.pedimento}\n"
|
||||||
dirigido=usuario,
|
f"{instance.document_type.nombre}"
|
||||||
mensaje=f"Se agregó el documento {instance.archivo} al pedimento {instance.pedimento.pedimento} \n {instance.document_type.nombre}",
|
)
|
||||||
)
|
|
||||||
# Notificar a otros roles (no importadores)
|
usuarios_org = CustomUser.objects.filter(
|
||||||
elif (usuario.is_superuser or usuario.groups.filter(name='Agente Aduanal').exists() or usuario.groups.filter(name='admin').exists()):
|
organizacion=instance.organizacion,
|
||||||
Notificacion.objects.create(
|
is_active=True,
|
||||||
tipo=tipo_info,
|
).prefetch_related('rfc')
|
||||||
dirigido=usuario,
|
|
||||||
mensaje=f"Se agregó el documento {instance.archivo} al pedimento {instance.pedimento.pedimento} \n {instance.document_type.nombre}",
|
for usuario in usuarios_org:
|
||||||
)
|
if not user_has_permission(usuario, 'notificaciones.receive'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Importadores: solo si el pedimento corresponde a uno de sus RFC
|
||||||
|
if usuario.is_importador:
|
||||||
|
if instance.pedimento.contribuyente not in usuario.rfc.all():
|
||||||
|
continue
|
||||||
|
|
||||||
|
Notificacion.objects.create(
|
||||||
|
tipo=tipo_info,
|
||||||
|
dirigido=usuario,
|
||||||
|
mensaje=mensaje,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,39 +1,38 @@
|
|||||||
from django.shortcuts import render
|
from rest_framework import viewsets, status
|
||||||
from rest_framework import viewsets
|
from rest_framework.decorators import action
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.exceptions import PermissionDenied
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from .models import Notificacion, TipoNotificacion
|
from .models import Notificacion, TipoNotificacion
|
||||||
from .serializers import NotificacionSerializer, TipoNotificacionSerializer
|
from .serializers import NotificacionSerializer, TipoNotificacionSerializer
|
||||||
from core.permissions import (
|
from core.permissions import require_permission
|
||||||
IsSameOrganization,
|
|
||||||
IsSameOrganizationDeveloper,
|
|
||||||
IsSameOrganizationAndAdmin,
|
|
||||||
IsSuperUser
|
|
||||||
)
|
|
||||||
# Create your views here.
|
|
||||||
|
|
||||||
class TipoNotificacionViewSet(viewsets.ModelViewSet):
|
class TipoNotificacionViewSet(viewsets.ModelViewSet):
|
||||||
queryset = TipoNotificacion.objects.all()
|
queryset = TipoNotificacion.objects.all()
|
||||||
serializer_class = TipoNotificacionSerializer
|
serializer_class = TipoNotificacionSerializer
|
||||||
http_method_names = ['get']
|
http_method_names = ['get']
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
|
|
||||||
|
|
||||||
my_tags = ['Notificaciones']
|
my_tags = ['Notificaciones']
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return self.queryset.order_by('tipo')
|
return self.queryset.order_by('tipo')
|
||||||
|
|
||||||
|
|
||||||
class NotificacionViewSet(viewsets.ModelViewSet):
|
class NotificacionViewSet(viewsets.ModelViewSet):
|
||||||
queryset = Notificacion.objects.all()
|
queryset = Notificacion.objects.all()
|
||||||
serializer_class = NotificacionSerializer
|
serializer_class = NotificacionSerializer
|
||||||
http_method_names = ['get', 'post', 'put', 'patch', 'delete']
|
http_method_names = ['get', 'post', 'put', 'patch', 'delete']
|
||||||
filterset_fields = ['visto']
|
filterset_fields = ['visto']
|
||||||
|
|
||||||
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
|
|
||||||
my_tags = ['Notificaciones']
|
my_tags = ['Notificaciones']
|
||||||
|
|
||||||
|
def get_permissions(self):
|
||||||
|
if self.action in ('list', 'retrieve'):
|
||||||
|
return [IsAuthenticated(), require_permission('notificaciones.view')()]
|
||||||
|
return [IsAuthenticated()]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
# Evita error en generación de esquema Swagger
|
|
||||||
if getattr(self, 'swagger_fake_view', False):
|
if getattr(self, 'swagger_fake_view', False):
|
||||||
return Notificacion.objects.none()
|
return Notificacion.objects.none()
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
@@ -45,6 +44,14 @@ class NotificacionViewSet(viewsets.ModelViewSet):
|
|||||||
if not self.request.user.is_authenticated:
|
if not self.request.user.is_authenticated:
|
||||||
raise PermissionDenied("Usuario no autenticado")
|
raise PermissionDenied("Usuario no autenticado")
|
||||||
if self.request.user.is_superuser:
|
if self.request.user.is_superuser:
|
||||||
# Allow superusers and admins to create notifications for any user
|
|
||||||
serializer.save()
|
serializer.save()
|
||||||
|
return
|
||||||
raise PermissionDenied("No tienes permiso para crear notificaciones para otros usuarios")
|
raise PermissionDenied("No tienes permiso para crear notificaciones para otros usuarios")
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'], url_path=r'by-task/(?P<task_id>[^/.]+)')
|
||||||
|
def by_task(self, request, task_id=None):
|
||||||
|
"""Recupera la notificación de una tarea de auditoría por su task_id (Celery)."""
|
||||||
|
notif = self.get_queryset().filter(datos__task_id=task_id).first()
|
||||||
|
if not notif:
|
||||||
|
return Response({'detail': 'No encontrada.'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
return Response(self.get_serializer(notif).data)
|
||||||
|
|||||||
@@ -1,18 +1,26 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from .models import Organizacion
|
from .models import Organizacion
|
||||||
# Register your models here.
|
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Organizacion)
|
||||||
class OrganizacionAdmin(admin.ModelAdmin):
|
class OrganizacionAdmin(admin.ModelAdmin):
|
||||||
list_display = ('id', 'nombre', 'rfc', 'email', 'telefono', 'is_active', 'is_verified', 'inicia', 'vencimiento')
|
list_display = ('nombre', 'rfc', 'hub_tenant_slug', 'email', 'owner', 'is_active', 'is_verified', 'inicio', 'vencimiento')
|
||||||
search_fields = ('nombre', 'rfc', 'email')
|
search_fields = ('nombre', 'rfc', 'email', 'hub_tenant_slug')
|
||||||
list_filter = ('is_active', 'is_verified')
|
list_filter = ('is_active', 'is_verified', 'is_agente_aduanal')
|
||||||
ordering = ('nombre',)
|
ordering = ('nombre',)
|
||||||
|
autocomplete_fields = ('owner',)
|
||||||
# class UsuarioOrganizacionAdmin(admin.ModelAdmin):
|
readonly_fields = ('created_at', 'updated_at')
|
||||||
# list_display = ('id', 'email', 'telefono', 'puesto', 'is_active', 'is_verified')
|
fieldsets = (
|
||||||
# search_fields = ('email', 'telefono', 'puesto')
|
(None, {'fields': ('nombre', 'rfc', 'titular', 'licencia')}),
|
||||||
# list_filter = ('is_active', 'is_verified')
|
('Integración Hub', {
|
||||||
# ordering = ('email',)
|
'fields': ('hub_tenant_slug',),
|
||||||
|
'description': 'Slug único del tenant en Aduanasoft Hub. Debe coincidir exactamente con el slug creado en el panel del Hub.',
|
||||||
admin.site.register(Organizacion)
|
}),
|
||||||
# admin.site.register(UsuarioOrganizacion)
|
('Contacto', {'fields': ('email', 'telefono', 'estado', 'ciudad')}),
|
||||||
|
('Administrador maestro', {'fields': ('owner',)}),
|
||||||
|
('Estado', {'fields': ('is_active', 'is_verified', 'is_agente_aduanal', 'apply_auto_download')}),
|
||||||
|
('Vigencia', {'fields': ('inicio', 'vencimiento')}),
|
||||||
|
('Observaciones', {'fields': ('observaciones',)}),
|
||||||
|
('Auditoría', {'fields': ('created_at', 'updated_at')}),
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.3 on 2026-05-19 13:45
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('organization', '0002_remove_organizacion_membretado_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='organizacion',
|
||||||
|
name='apply_auto_download',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
||||||
25
api/organization/migrations/0004_organizacion_owner.py
Normal file
25
api/organization/migrations/0004_organizacion_owner.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('organization', '0003_organizacion_apply_auto_download'),
|
||||||
|
('cuser', '0005_customuser_rfc_fk_to_m2m'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='organizacion',
|
||||||
|
name='owner',
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name='organizaciones_que_administra',
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -40,8 +40,19 @@ class Organizacion(models.Model):
|
|||||||
estado = models.CharField(max_length=50)
|
estado = models.CharField(max_length=50)
|
||||||
ciudad = models.CharField(max_length=50)
|
ciudad = models.CharField(max_length=50)
|
||||||
|
|
||||||
|
# Administrador maestro: acceso total a su org, no puede ser removido de su rol por otros admins.
|
||||||
|
# on_delete=PROTECT: no se puede eliminar el usuario sin reasignar el ownership primero.
|
||||||
|
owner = models.ForeignKey(
|
||||||
|
'cuser.CustomUser',
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='organizaciones_que_administra',
|
||||||
|
)
|
||||||
|
|
||||||
is_active = models.BooleanField(default=True)
|
is_active = models.BooleanField(default=True)
|
||||||
is_verified = models.BooleanField(default=False)
|
is_verified = models.BooleanField(default=False)
|
||||||
|
apply_auto_download = models.BooleanField(default=False)
|
||||||
|
|
||||||
inicio = models.DateField(null=True, blank=True)
|
inicio = models.DateField(null=True, blank=True)
|
||||||
vencimiento = models.DateField(null=True, blank=True)
|
vencimiento = models.DateField(null=True, blank=True)
|
||||||
@@ -51,6 +62,9 @@ class Organizacion(models.Model):
|
|||||||
|
|
||||||
observaciones = models.TextField(null=True, blank=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
|
@property
|
||||||
def espacio_utilizado(self):
|
def espacio_utilizado(self):
|
||||||
|
|||||||
@@ -1,8 +1,28 @@
|
|||||||
from django.db.models.signals import post_save
|
from django.db.models.signals import post_save
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
|
|
||||||
from .models import Organizacion, UsoAlmacenamiento
|
from .models import Organizacion, UsoAlmacenamiento
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=Organizacion)
|
@receiver(post_save, sender=Organizacion)
|
||||||
def crear_uso_almacenamiento(sender, instance, created, **kwargs):
|
def crear_uso_almacenamiento(sender, instance, created, **kwargs):
|
||||||
if created:
|
if created:
|
||||||
UsoAlmacenamiento.objects.create(organizacion=instance, espacio_utilizado=0)
|
UsoAlmacenamiento.objects.create(organizacion=instance, espacio_utilizado=0)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=Organizacion)
|
||||||
|
def crear_roles_default(sender, instance, created, **kwargs):
|
||||||
|
"""Al crear una organización nueva, genera automáticamente los 5 roles por defecto
|
||||||
|
con sus permisos. Depende de que el catálogo RolePermission ya exista (post-migration)."""
|
||||||
|
if not created:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from api.rbac.roles import crear_roles_para_organizacion
|
||||||
|
crear_roles_para_organizacion(instance)
|
||||||
|
except Exception:
|
||||||
|
# Si la app rbac aún no está migrada (ej. primer deploy), no bloquear la creación de org
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).warning(
|
||||||
|
'No se pudieron crear roles para org %s — verifica que rbac esté migrado.',
|
||||||
|
instance.id,
|
||||||
|
)
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ from core.permissions import (
|
|||||||
IsSameOrganization,
|
IsSameOrganization,
|
||||||
IsSameOrganizationDeveloper,
|
IsSameOrganizationDeveloper,
|
||||||
IsSameOrganizationAndAdmin,
|
IsSameOrganizationAndAdmin,
|
||||||
IsSuperUser
|
IsSuperUser,
|
||||||
|
get_org_context,
|
||||||
|
is_internal_service_request,
|
||||||
|
user_has_permission,
|
||||||
)
|
)
|
||||||
from .serializers import OrganizacionSerializer, UsoAlmacenamientoSerializer
|
from .serializers import OrganizacionSerializer, UsoAlmacenamientoSerializer
|
||||||
from .models import Organizacion, UsoAlmacenamiento
|
from .models import Organizacion, UsoAlmacenamiento
|
||||||
@@ -32,21 +35,19 @@ class ViewSetOrganizacion(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltr
|
|||||||
my_tags = ['Organizaciones']
|
my_tags = ['Organizaciones']
|
||||||
|
|
||||||
def get_queryset(self):
|
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:
|
||||||
return Organizacion.objects.none()
|
return Organizacion.objects.none()
|
||||||
|
|
||||||
if self.request.user.is_superuser:
|
if is_internal_service_request(self.request):
|
||||||
# Superuser can see all organizations
|
|
||||||
return Organizacion.objects.all()
|
return Organizacion.objects.all()
|
||||||
|
|
||||||
if (self.request.user.groups.filter(name='admin').exists() or self.request.user.groups.filter('developer').exists() or self.request.user.groups.filter('user')) and self.request.user.groups.filter(name='Agente Aduanal').exists():
|
org = get_org_context(user)
|
||||||
# Importers can only see their own organization
|
if not org:
|
||||||
return Organizacion.objects.filter(users=self.request.user)
|
return Organizacion.objects.none()
|
||||||
|
|
||||||
if self.request.user.groups.filter(name='importador').exists():
|
# Superuser ve solo su org activa, no todas
|
||||||
return Organizacion.objects.filter(users=self.request.user)
|
return Organizacion.objects.filter(id=org.id)
|
||||||
|
|
||||||
return Organizacion.objects.none()
|
|
||||||
|
|
||||||
class UsoAlmacenamientoViewSet(LoggingMixin, viewsets.ReadOnlyModelViewSet):
|
class UsoAlmacenamientoViewSet(LoggingMixin, viewsets.ReadOnlyModelViewSet):
|
||||||
"""
|
"""
|
||||||
@@ -60,31 +61,26 @@ class UsoAlmacenamientoViewSet(LoggingMixin, viewsets.ReadOnlyModelViewSet):
|
|||||||
my_tags = ['Uso de Almacenamiento']
|
my_tags = ['Uso de Almacenamiento']
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'):
|
if not self.request.user.is_authenticated:
|
||||||
return UsoAlmacenamiento.objects.none()
|
return UsoAlmacenamiento.objects.none()
|
||||||
|
|
||||||
|
if is_internal_service_request(self.request):
|
||||||
if self.request.user.is_superuser:
|
|
||||||
# Superuser can see all storage usage
|
|
||||||
return UsoAlmacenamiento.objects.all()
|
return UsoAlmacenamiento.objects.all()
|
||||||
|
|
||||||
if (self.request.user.groups.filter(name='developer').exists() or
|
org = get_org_context(self.request.user)
|
||||||
self.request.user.groups.filter(name='admin').exists() or
|
if not org:
|
||||||
self.request.user.groups.filter(name='user').exists()) and self.request.user.groups.filter(name='Agente Aduanal').exists():
|
return UsoAlmacenamiento.objects.none()
|
||||||
# Developers, Admins, and Users can see their organization's storage usage
|
|
||||||
return UsoAlmacenamiento.objects.filter(organizacion=self.request.user.organizacion)
|
|
||||||
|
|
||||||
if self.request.user.groups.filter(name='importador').exists():
|
if self.request.user.is_importador:
|
||||||
# Importers can only see their own organization's storage usage
|
|
||||||
raise PermissionDenied("Los importadores no tienen acceso al uso de almacenamiento.")
|
raise PermissionDenied("Los importadores no tienen acceso al uso de almacenamiento.")
|
||||||
|
|
||||||
return UsoAlmacenamiento.objects.none()
|
return UsoAlmacenamiento.objects.filter(organizacion=org)
|
||||||
|
|
||||||
@action(detail=False, methods=['get'])
|
@action(detail=False, methods=['get'])
|
||||||
def mi_organizacion(self, request):
|
def mi_organizacion(self, request):
|
||||||
|
|
||||||
"""Obtiene el uso de almacenamiento de la organización del usuario actual"""
|
"""Obtiene el uso de almacenamiento de la organización del usuario actual"""
|
||||||
organizacion = request.user.organizacion
|
organizacion = get_org_context(request.user)
|
||||||
|
|
||||||
# Obtener o crear el registro de uso
|
# Obtener o crear el registro de uso
|
||||||
uso, created = UsoAlmacenamiento.objects.get_or_create(
|
uso, created = UsoAlmacenamiento.objects.get_or_create(
|
||||||
|
|||||||
0
api/rbac/__init__.py
Normal file
0
api/rbac/__init__.py
Normal file
99
api/rbac/admin.py
Normal file
99
api/rbac/admin.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .models import OrganizationRole, RolePermission, UserPermission, UserRole
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(RolePermission)
|
||||||
|
class RolePermissionAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('codename', 'modulo', 'descripcion')
|
||||||
|
list_filter = ('modulo',)
|
||||||
|
search_fields = ('codename', 'descripcion')
|
||||||
|
ordering = ('modulo', 'codename')
|
||||||
|
|
||||||
|
def get_readonly_fields(self, request, obj=None):
|
||||||
|
# Al editar un permiso existente los campos son readonly para evitar inconsistencias
|
||||||
|
if obj:
|
||||||
|
return ('codename', 'modulo', 'descripcion')
|
||||||
|
return ()
|
||||||
|
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
return request.user.is_superuser
|
||||||
|
|
||||||
|
def has_change_permission(self, request, obj=None):
|
||||||
|
return request.user.is_superuser
|
||||||
|
|
||||||
|
def has_delete_permission(self, request, obj=None):
|
||||||
|
return request.user.is_superuser
|
||||||
|
|
||||||
|
|
||||||
|
class UserRoleInline(admin.TabularInline):
|
||||||
|
model = UserRole
|
||||||
|
extra = 0
|
||||||
|
autocomplete_fields = ('user',)
|
||||||
|
readonly_fields = ('created_at',)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(OrganizationRole)
|
||||||
|
class OrganizationRoleAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('nombre', 'organizacion', 'is_admin_role', 'permisos_count', 'usuarios_count')
|
||||||
|
list_filter = ('organizacion', 'is_admin_role')
|
||||||
|
search_fields = ('nombre', 'organizacion__nombre')
|
||||||
|
filter_horizontal = ('permissions',)
|
||||||
|
inlines = (UserRoleInline,)
|
||||||
|
readonly_fields = ('created_at', 'updated_at')
|
||||||
|
|
||||||
|
def permisos_count(self, obj):
|
||||||
|
return obj.permissions.count()
|
||||||
|
permisos_count.short_description = 'Permisos'
|
||||||
|
|
||||||
|
def usuarios_count(self, obj):
|
||||||
|
return obj.user_roles.count()
|
||||||
|
usuarios_count.short_description = 'Usuarios'
|
||||||
|
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
return request.user.is_superuser
|
||||||
|
|
||||||
|
def has_delete_permission(self, request, obj=None):
|
||||||
|
if obj and obj.is_admin_role:
|
||||||
|
return False
|
||||||
|
return request.user.is_superuser
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(UserRole)
|
||||||
|
class UserRoleAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('user', 'role', 'organizacion', 'created_at')
|
||||||
|
list_filter = ('role__organizacion', 'role__nombre')
|
||||||
|
search_fields = ('user__username', 'user__email', 'role__nombre')
|
||||||
|
autocomplete_fields = ('user',)
|
||||||
|
readonly_fields = ('created_at',)
|
||||||
|
|
||||||
|
def organizacion(self, obj):
|
||||||
|
return obj.role.organizacion
|
||||||
|
organizacion.short_description = 'Organización'
|
||||||
|
|
||||||
|
def save_model(self, request, obj, form, change):
|
||||||
|
# Bloquear remoción del rol admin_role al owner de la org
|
||||||
|
if change and obj.role.is_admin_role:
|
||||||
|
org = obj.role.organizacion
|
||||||
|
if hasattr(org, 'owner') and org.owner == obj.user:
|
||||||
|
from django.contrib import messages
|
||||||
|
self.message_user(
|
||||||
|
request,
|
||||||
|
'No se puede remover el rol de administrador maestro al owner de la organización.',
|
||||||
|
level=messages.ERROR,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
super().save_model(request, obj, form, change)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(UserPermission)
|
||||||
|
class UserPermissionAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('user', 'permission', 'granted', 'organizacion', 'created_at')
|
||||||
|
list_filter = ('granted', 'permission__modulo')
|
||||||
|
search_fields = ('user__username', 'user__email', 'permission__codename')
|
||||||
|
autocomplete_fields = ('user',)
|
||||||
|
readonly_fields = ('created_at',)
|
||||||
|
|
||||||
|
def organizacion(self, obj):
|
||||||
|
return getattr(obj.user, 'organizacion', '—')
|
||||||
|
organizacion.short_description = 'Organización'
|
||||||
8
api/rbac/apps.py
Normal file
8
api/rbac/apps.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class RbacConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'api.rbac'
|
||||||
|
label = 'rbac'
|
||||||
|
verbose_name = 'RBAC'
|
||||||
0
api/rbac/management/__init__.py
Normal file
0
api/rbac/management/__init__.py
Normal file
0
api/rbac/management/commands/__init__.py
Normal file
0
api/rbac/management/commands/__init__.py
Normal file
101
api/rbac/management/commands/sync_rbac.py
Normal file
101
api/rbac/management/commands/sync_rbac.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
"""
|
||||||
|
Sincroniza el catálogo de permisos de roles.py con la base de datos.
|
||||||
|
|
||||||
|
Uso básico (solo catálogo):
|
||||||
|
python manage.py sync_rbac
|
||||||
|
|
||||||
|
Con propagación a roles existentes (agrega permisos nuevos a roles que ya existen):
|
||||||
|
python manage.py sync_rbac --roles
|
||||||
|
|
||||||
|
Con listado de lo que hay actualmente:
|
||||||
|
python manage.py sync_rbac --list
|
||||||
|
"""
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from api.rbac.roles import DEFAULT_ROLES, PERMISSIONS_CATALOG
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Sincroniza el catálogo de permisos (roles.py → BD) sin necesidad de migración.'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
'--roles',
|
||||||
|
action='store_true',
|
||||||
|
help='Propaga los permisos nuevos a los OrganizationRoles existentes que coincidan con DEFAULT_ROLES.',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--list',
|
||||||
|
action='store_true',
|
||||||
|
help='Lista los permisos actuales en la BD agrupados por módulo.',
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
from api.rbac.models import OrganizationRole, RolePermission
|
||||||
|
|
||||||
|
if options['list']:
|
||||||
|
self._list_permisos(RolePermission)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._sync_catalogo(RolePermission)
|
||||||
|
|
||||||
|
if options['roles']:
|
||||||
|
self._sync_roles(RolePermission, OrganizationRole)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _sync_catalogo(self, RolePermission):
|
||||||
|
creados = 0
|
||||||
|
existentes = 0
|
||||||
|
|
||||||
|
for codename, descripcion, modulo in PERMISSIONS_CATALOG:
|
||||||
|
_, created = RolePermission.objects.get_or_create(
|
||||||
|
codename=codename,
|
||||||
|
defaults={'descripcion': descripcion, 'modulo': modulo},
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
self.stdout.write(self.style.SUCCESS(f' [+] {codename} ({modulo})'))
|
||||||
|
creados += 1
|
||||||
|
else:
|
||||||
|
existentes += 1
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f'\nCatálogo: {creados} permisos creados, {existentes} ya existían.')
|
||||||
|
)
|
||||||
|
|
||||||
|
def _sync_roles(self, RolePermission, OrganizationRole):
|
||||||
|
perms_map = {p.codename: p for p in RolePermission.objects.all()}
|
||||||
|
roles_actualizados = 0
|
||||||
|
permisos_agregados = 0
|
||||||
|
|
||||||
|
for org_role in OrganizationRole.objects.select_related('organizacion').prefetch_related('permissions'):
|
||||||
|
config = DEFAULT_ROLES.get(org_role.nombre)
|
||||||
|
if not config:
|
||||||
|
continue
|
||||||
|
|
||||||
|
esperados = {c: perms_map[c] for c in config['permissions'] if c in perms_map}
|
||||||
|
actuales = {p.codename for p in org_role.permissions.all()}
|
||||||
|
nuevos = {c: p for c, p in esperados.items() if c not in actuales}
|
||||||
|
|
||||||
|
if nuevos:
|
||||||
|
org_role.permissions.add(*nuevos.values())
|
||||||
|
roles_actualizados += 1
|
||||||
|
permisos_agregados += len(nuevos)
|
||||||
|
self.stdout.write(
|
||||||
|
f' Rol "{org_role.nombre}" en {org_role.organizacion}: '
|
||||||
|
f'+{len(nuevos)} → {", ".join(nuevos.keys())}'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f'\nRoles: {roles_actualizados} roles actualizados, {permisos_agregados} asignaciones nuevas.'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _list_permisos(self, RolePermission):
|
||||||
|
modulo_actual = None
|
||||||
|
for perm in RolePermission.objects.order_by('modulo', 'codename'):
|
||||||
|
if perm.modulo != modulo_actual:
|
||||||
|
modulo_actual = perm.modulo
|
||||||
|
self.stdout.write(self.style.HTTP_INFO(f'\n {modulo_actual}'))
|
||||||
|
self.stdout.write(f' {perm.codename:<40} {perm.descripcion}')
|
||||||
116
api/rbac/migrations/0001_initial.py
Normal file
116
api/rbac/migrations/0001_initial.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import uuid
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('organization', '0003_organizacion_apply_auto_download'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='RolePermission',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||||
|
('codename', models.CharField(max_length=100, unique=True)),
|
||||||
|
('descripcion', models.CharField(max_length=255)),
|
||||||
|
('modulo', models.CharField(max_length=50)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Permiso',
|
||||||
|
'verbose_name_plural': 'Permisos',
|
||||||
|
'db_table': 'rbac_role_permission',
|
||||||
|
'ordering': ['modulo', 'codename'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='OrganizationRole',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('nombre', models.CharField(max_length=100)),
|
||||||
|
('descripcion', models.CharField(blank=True, max_length=255)),
|
||||||
|
('is_admin_role', models.BooleanField(default=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('organizacion', models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name='roles',
|
||||||
|
to='organization.organizacion',
|
||||||
|
)),
|
||||||
|
('permissions', models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
related_name='roles',
|
||||||
|
to='rbac.rolepermission',
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Rol de Organización',
|
||||||
|
'verbose_name_plural': 'Roles de Organización',
|
||||||
|
'db_table': 'rbac_organization_role',
|
||||||
|
'ordering': ['nombre'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='organizationrole',
|
||||||
|
constraint=models.UniqueConstraint(fields=['organizacion', 'nombre'], name='unique_role_per_org'),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='UserRole',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('user', models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name='user_roles',
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
)),
|
||||||
|
('role', models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name='user_roles',
|
||||||
|
to='rbac.organizationrole',
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Rol de Usuario',
|
||||||
|
'verbose_name_plural': 'Roles de Usuario',
|
||||||
|
'db_table': 'rbac_user_role',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='userrole',
|
||||||
|
constraint=models.UniqueConstraint(fields=['user', 'role'], name='unique_user_role'),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='UserPermission',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('granted', models.BooleanField(default=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('user', models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name='rbac_permissions',
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
)),
|
||||||
|
('permission', models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name='user_overrides',
|
||||||
|
to='rbac.rolepermission',
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Permiso Singular',
|
||||||
|
'verbose_name_plural': 'Permisos Singulares',
|
||||||
|
'db_table': 'rbac_user_permission',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='userpermission',
|
||||||
|
constraint=models.UniqueConstraint(fields=['user', 'permission'], name='unique_user_permission'),
|
||||||
|
),
|
||||||
|
]
|
||||||
88
api/rbac/migrations/0002_data_permissions.py
Normal file
88
api/rbac/migrations/0002_data_permissions.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
"""
|
||||||
|
Data migration que:
|
||||||
|
1. Crea el catálogo global de permisos (RolePermission).
|
||||||
|
2. Para cada Organizacion existente, crea los 5 roles por defecto con sus permisos.
|
||||||
|
3. Para cada CustomUser existente, mapea sus auth.Group actuales al UserRole equivalente.
|
||||||
|
|
||||||
|
Usa get_or_create en todos los pasos — segura de ejecutar múltiples veces.
|
||||||
|
"""
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
# Importamos solo constantes (no modelos ni funciones con imports de Django)
|
||||||
|
# para que la migration sea estable ante futuros refactors del código de la app.
|
||||||
|
from api.rbac.roles import PERMISSIONS_CATALOG, DEFAULT_ROLES
|
||||||
|
|
||||||
|
|
||||||
|
def _crear_permisos(RolePermission):
|
||||||
|
perms_map = {}
|
||||||
|
for codename, descripcion, modulo in PERMISSIONS_CATALOG:
|
||||||
|
perm, _ = RolePermission.objects.get_or_create(
|
||||||
|
codename=codename,
|
||||||
|
defaults={'descripcion': descripcion, 'modulo': modulo},
|
||||||
|
)
|
||||||
|
perms_map[codename] = perm
|
||||||
|
return perms_map
|
||||||
|
|
||||||
|
|
||||||
|
def _crear_roles_org(OrganizationRole, org, perms_map):
|
||||||
|
for nombre, config in DEFAULT_ROLES.items():
|
||||||
|
role, created = OrganizationRole.objects.get_or_create(
|
||||||
|
organizacion=org,
|
||||||
|
nombre=nombre,
|
||||||
|
defaults={
|
||||||
|
'descripcion': config['descripcion'],
|
||||||
|
'is_admin_role': config.get('is_admin_role', False),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
role_perms = [perms_map[c] for c in config['permissions'] if c in perms_map]
|
||||||
|
role.permissions.set(role_perms)
|
||||||
|
|
||||||
|
|
||||||
|
def seed_rbac_data(apps, schema_editor):
|
||||||
|
RolePermission = apps.get_model('rbac', 'RolePermission')
|
||||||
|
OrganizationRole = apps.get_model('rbac', 'OrganizationRole')
|
||||||
|
UserRole = apps.get_model('rbac', 'UserRole')
|
||||||
|
Organizacion = apps.get_model('organization', 'Organizacion')
|
||||||
|
CustomUser = apps.get_model('cuser', 'CustomUser')
|
||||||
|
|
||||||
|
# Paso 1 — Catálogo de permisos
|
||||||
|
perms_map = _crear_permisos(RolePermission)
|
||||||
|
|
||||||
|
# Paso 2 — Roles por defecto para cada organización existente
|
||||||
|
for org in Organizacion.objects.all():
|
||||||
|
_crear_roles_org(OrganizationRole, org, perms_map)
|
||||||
|
|
||||||
|
# Paso 3 — Mapeo de usuarios: auth.Group → UserRole
|
||||||
|
# Solo usuarios que tengan organización asignada y grupos asignados
|
||||||
|
for user in CustomUser.objects.filter(organizacion__isnull=False).prefetch_related('groups'):
|
||||||
|
for group in user.groups.all():
|
||||||
|
try:
|
||||||
|
role = OrganizationRole.objects.get(
|
||||||
|
organizacion=user.organizacion,
|
||||||
|
nombre=group.name,
|
||||||
|
)
|
||||||
|
UserRole.objects.get_or_create(user=user, role=role)
|
||||||
|
except OrganizationRole.DoesNotExist:
|
||||||
|
# El grupo no tiene equivalente en los roles por defecto — se ignora
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def reverse_seed(apps, schema_editor):
|
||||||
|
# Revertir borra todos los datos RBAC. Los auth.Group originales no se tocan.
|
||||||
|
apps.get_model('rbac', 'UserRole').objects.all().delete()
|
||||||
|
apps.get_model('rbac', 'OrganizationRole').objects.all().delete()
|
||||||
|
apps.get_model('rbac', 'RolePermission').objects.all().delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('rbac', '0001_initial'),
|
||||||
|
('cuser', '0005_customuser_rfc_fk_to_m2m'),
|
||||||
|
('organization', '0003_organizacion_apply_auto_download'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(seed_rbac_data, reverse_code=reverse_seed),
|
||||||
|
]
|
||||||
56
api/rbac/migrations/0003_notificaciones_receive.py
Normal file
56
api/rbac/migrations/0003_notificaciones_receive.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""
|
||||||
|
Agrega el permiso notificaciones.receive al catálogo y lo asigna a todos los
|
||||||
|
OrganizationRole que correspondan a los 5 roles por defecto (en todas las orgs).
|
||||||
|
"""
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
NUEVO_PERMISO = (
|
||||||
|
'notificaciones.receive',
|
||||||
|
'Recibir notificaciones automáticas de eventos',
|
||||||
|
'notificaciones',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Todos los roles por defecto deben recibir notificaciones
|
||||||
|
ROLES_CON_PERMISO = ['admin', 'developer', 'Agente Aduanal', 'user', 'Importador']
|
||||||
|
|
||||||
|
|
||||||
|
def agregar_notificaciones_receive(apps, schema_editor):
|
||||||
|
RolePermission = apps.get_model('rbac', 'RolePermission')
|
||||||
|
OrganizationRole = apps.get_model('rbac', 'OrganizationRole')
|
||||||
|
|
||||||
|
codename, descripcion, modulo = NUEVO_PERMISO
|
||||||
|
perm, _ = RolePermission.objects.get_or_create(
|
||||||
|
codename=codename,
|
||||||
|
defaults={'descripcion': descripcion, 'modulo': modulo},
|
||||||
|
)
|
||||||
|
|
||||||
|
roles = OrganizationRole.objects.filter(nombre__in=ROLES_CON_PERMISO)
|
||||||
|
for role in roles:
|
||||||
|
role.permissions.add(perm)
|
||||||
|
|
||||||
|
|
||||||
|
def revertir(apps, schema_editor):
|
||||||
|
RolePermission = apps.get_model('rbac', 'RolePermission')
|
||||||
|
OrganizationRole = apps.get_model('rbac', 'OrganizationRole')
|
||||||
|
|
||||||
|
try:
|
||||||
|
perm = RolePermission.objects.get(codename='notificaciones.receive')
|
||||||
|
except RolePermission.DoesNotExist:
|
||||||
|
return
|
||||||
|
|
||||||
|
for role in OrganizationRole.objects.all():
|
||||||
|
role.permissions.remove(perm)
|
||||||
|
|
||||||
|
perm.delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('rbac', '0002_data_permissions'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(agregar_notificaciones_receive, reverse_code=revertir),
|
||||||
|
]
|
||||||
57
api/rbac/migrations/0004_auditoria_permissions.py
Normal file
57
api/rbac/migrations/0004_auditoria_permissions.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""
|
||||||
|
Agrega los permisos auditoria.view y auditoria.process al catálogo y los asigna
|
||||||
|
a los roles admin, developer (ambos) y Agente Aduanal (solo view).
|
||||||
|
"""
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
NUEVOS_PERMISOS = [
|
||||||
|
('auditoria.view', 'Ver estado y resultados de auditoría VUCEM', 'auditoria'),
|
||||||
|
('auditoria.process', 'Lanzar procesos de auditoría y reauditoría', 'auditoria'),
|
||||||
|
]
|
||||||
|
|
||||||
|
ROLES_AUDITORIA_FULL = ['admin', 'developer']
|
||||||
|
ROLES_AUDITORIA_VIEW = ['Agente Aduanal']
|
||||||
|
|
||||||
|
|
||||||
|
def agregar_auditoria(apps, schema_editor):
|
||||||
|
RolePermission = apps.get_model('rbac', 'RolePermission')
|
||||||
|
OrganizationRole = apps.get_model('rbac', 'OrganizationRole')
|
||||||
|
|
||||||
|
perms = {}
|
||||||
|
for codename, descripcion, modulo in NUEVOS_PERMISOS:
|
||||||
|
perm, _ = RolePermission.objects.get_or_create(
|
||||||
|
codename=codename,
|
||||||
|
defaults={'descripcion': descripcion, 'modulo': modulo},
|
||||||
|
)
|
||||||
|
perms[codename] = perm
|
||||||
|
|
||||||
|
for role in OrganizationRole.objects.filter(nombre__in=ROLES_AUDITORIA_FULL):
|
||||||
|
role.permissions.add(perms['auditoria.view'], perms['auditoria.process'])
|
||||||
|
|
||||||
|
for role in OrganizationRole.objects.filter(nombre__in=ROLES_AUDITORIA_VIEW):
|
||||||
|
role.permissions.add(perms['auditoria.view'])
|
||||||
|
|
||||||
|
|
||||||
|
def revertir(apps, schema_editor):
|
||||||
|
RolePermission = apps.get_model('rbac', 'RolePermission')
|
||||||
|
OrganizationRole = apps.get_model('rbac', 'OrganizationRole')
|
||||||
|
|
||||||
|
for codename, _, _ in NUEVOS_PERMISOS:
|
||||||
|
try:
|
||||||
|
perm = RolePermission.objects.get(codename=codename)
|
||||||
|
except RolePermission.DoesNotExist:
|
||||||
|
continue
|
||||||
|
for role in OrganizationRole.objects.all():
|
||||||
|
role.permissions.remove(perm)
|
||||||
|
perm.delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('rbac', '0003_notificaciones_receive'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(agregar_auditoria, reverse_code=revertir),
|
||||||
|
]
|
||||||
18
api/rbac/migrations/0005_alter_rolepermission_id.py
Normal file
18
api/rbac/migrations/0005_alter_rolepermission_id.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.3 on 2026-05-26 13:40
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('rbac', '0004_auditoria_permissions'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='rolepermission',
|
||||||
|
name='id',
|
||||||
|
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
api/rbac/migrations/__init__.py
Normal file
0
api/rbac/migrations/__init__.py
Normal file
109
api/rbac/models.py
Normal file
109
api/rbac/models.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class RolePermission(models.Model):
|
||||||
|
"""Catálogo global de permisos de la aplicación. Se define una vez y es compartido por todas las orgs."""
|
||||||
|
codename = models.CharField(max_length=100, unique=True)
|
||||||
|
descripcion = models.CharField(max_length=255)
|
||||||
|
modulo = models.CharField(max_length=50)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.codename
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'rbac_role_permission'
|
||||||
|
ordering = ['modulo', 'codename']
|
||||||
|
verbose_name = 'Permiso'
|
||||||
|
verbose_name_plural = 'Permisos'
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationRole(models.Model):
|
||||||
|
"""Rol de una organización. Cada org tiene su propio conjunto de roles con sus permisos."""
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
organizacion = models.ForeignKey(
|
||||||
|
'organization.Organizacion',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='roles',
|
||||||
|
)
|
||||||
|
nombre = models.CharField(max_length=100)
|
||||||
|
descripcion = models.CharField(max_length=255, blank=True)
|
||||||
|
# El rol admin maestro no puede ser removido del owner de la org
|
||||||
|
is_admin_role = models.BooleanField(default=False)
|
||||||
|
permissions = models.ManyToManyField(
|
||||||
|
RolePermission,
|
||||||
|
blank=True,
|
||||||
|
related_name='roles',
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.nombre} ({self.organizacion})'
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'rbac_organization_role'
|
||||||
|
ordering = ['nombre']
|
||||||
|
verbose_name = 'Rol de Organización'
|
||||||
|
verbose_name_plural = 'Roles de Organización'
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(fields=['organizacion', 'nombre'], name='unique_role_per_org'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class UserRole(models.Model):
|
||||||
|
"""Asignación de un rol a un usuario dentro de su organización."""
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='user_roles',
|
||||||
|
)
|
||||||
|
role = models.ForeignKey(
|
||||||
|
OrganizationRole,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='user_roles',
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.user} → {self.role.nombre}'
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'rbac_user_role'
|
||||||
|
verbose_name = 'Rol de Usuario'
|
||||||
|
verbose_name_plural = 'Roles de Usuario'
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(fields=['user', 'role'], name='unique_user_role'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class UserPermission(models.Model):
|
||||||
|
"""Permiso singular asignado directamente a un usuario, sin necesidad de rol.
|
||||||
|
granted=True otorga, granted=False deniega explícitamente (override sobre roles)."""
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='rbac_permissions',
|
||||||
|
)
|
||||||
|
permission = models.ForeignKey(
|
||||||
|
RolePermission,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='user_overrides',
|
||||||
|
)
|
||||||
|
granted = models.BooleanField(default=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
estado = 'GRANT' if self.granted else 'DENY'
|
||||||
|
return f'{estado}: {self.user} → {self.permission.codename}'
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'rbac_user_permission'
|
||||||
|
verbose_name = 'Permiso Singular'
|
||||||
|
verbose_name_plural = 'Permisos Singulares'
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(fields=['user', 'permission'], name='unique_user_permission'),
|
||||||
|
]
|
||||||
176
api/rbac/roles.py
Normal file
176
api/rbac/roles.py
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
# Catálogo de permisos y configuración de roles por defecto.
|
||||||
|
# Este módulo es importado tanto por la data migration como por el signal de Organizacion,
|
||||||
|
# por lo que NO debe importar modelos directamente al nivel de módulo.
|
||||||
|
|
||||||
|
# --- CATÁLOGO DE PERMISOS ---
|
||||||
|
# (codename, descripcion, modulo)
|
||||||
|
PERMISSIONS_CATALOG = [
|
||||||
|
# Usuarios
|
||||||
|
('usuarios.view', 'Ver usuarios de la organización', 'usuarios'),
|
||||||
|
('usuarios.create', 'Crear usuarios en la organización', 'usuarios'),
|
||||||
|
('usuarios.edit', 'Modificar usuarios de la organización', 'usuarios'),
|
||||||
|
('usuarios.delete', 'Eliminar usuarios de la organización', 'usuarios'),
|
||||||
|
('usuarios.manage_roles', 'Asignar y revocar roles a usuarios', 'usuarios'),
|
||||||
|
('usuarios.change_password', 'Cambiar contraseña de otro usuario', 'usuarios'),
|
||||||
|
# Pedimentos
|
||||||
|
('pedimentos.view', 'Ver pedimentos', 'pedimentos'),
|
||||||
|
('pedimentos.create', 'Crear e importar pedimentos', 'pedimentos'),
|
||||||
|
('pedimentos.edit', 'Modificar pedimentos', 'pedimentos'),
|
||||||
|
('pedimentos.delete', 'Eliminar pedimentos', 'pedimentos'),
|
||||||
|
('pedimentos.process', 'Procesar pedimentos contra VUCEM', 'pedimentos'),
|
||||||
|
# Importadores
|
||||||
|
('importadores.view', 'Ver importadores', 'importadores'),
|
||||||
|
('importadores.create', 'Crear importadores', 'importadores'),
|
||||||
|
('importadores.edit', 'Modificar importadores', 'importadores'),
|
||||||
|
('importadores.delete', 'Eliminar importadores', 'importadores'),
|
||||||
|
# Partidas
|
||||||
|
('partidas.view', 'Ver partidas', 'partidas'),
|
||||||
|
('partidas.create', 'Crear partidas', 'partidas'),
|
||||||
|
('partidas.edit', 'Modificar partidas', 'partidas'),
|
||||||
|
('partidas.delete', 'Eliminar partidas', 'partidas'),
|
||||||
|
# Remesas
|
||||||
|
('remesas.view', 'Ver remesas', 'remesas'),
|
||||||
|
# COVEs
|
||||||
|
('coves.view', 'Ver COVEs', 'coves'),
|
||||||
|
('coves.create', 'Crear COVEs', 'coves'),
|
||||||
|
('coves.edit', 'Modificar COVEs', 'coves'),
|
||||||
|
('coves.delete', 'Eliminar COVEs', 'coves'),
|
||||||
|
# E-Documents
|
||||||
|
('edocuments.view', 'Ver E-Documents', 'edocuments'),
|
||||||
|
('edocuments.create', 'Crear E-Documents', 'edocuments'),
|
||||||
|
('edocuments.edit', 'Modificar E-Documents', 'edocuments'),
|
||||||
|
('edocuments.delete', 'Eliminar E-Documents', 'edocuments'),
|
||||||
|
# Acuses
|
||||||
|
('acuses.view', 'Ver acuses', 'acuses'),
|
||||||
|
# Documentos (expediente)
|
||||||
|
('documentos.view', 'Ver documentos del expediente', 'documentos'),
|
||||||
|
('documentos.upload', 'Cargar documentos', 'documentos'),
|
||||||
|
('documentos.download', 'Descargar documentos y ZIPs', 'documentos'),
|
||||||
|
('documentos.delete', 'Eliminar documentos del expediente', 'documentos'),
|
||||||
|
# VUCEM
|
||||||
|
('vucem.view', 'Ver credenciales VUCEM', 'vucem'),
|
||||||
|
('vucem.manage', 'Gestionar credenciales VUCEM', 'vucem'),
|
||||||
|
# Reportes
|
||||||
|
('reportes.view', 'Ver reportes y dashboard', 'reportes'),
|
||||||
|
('reportes.export', 'Exportar reportes a CSV/Excel', 'reportes'),
|
||||||
|
# DataStage
|
||||||
|
('datastage.view', 'Ver DataStages', 'datastage'),
|
||||||
|
('datastage.create', 'Crear DataStages', 'datastage'),
|
||||||
|
('datastage.process', 'Procesar DataStages', 'datastage'),
|
||||||
|
('datastage.delete', 'Eliminar DataStages', 'datastage'),
|
||||||
|
# Organización
|
||||||
|
('organizacion.view', 'Ver datos de la organización', 'organizacion'),
|
||||||
|
('organizacion.edit', 'Modificar datos de la organización', 'organizacion'),
|
||||||
|
# Notificaciones
|
||||||
|
('notificaciones.view', 'Ver notificaciones propias', 'notificaciones'),
|
||||||
|
('notificaciones.receive', 'Recibir notificaciones automáticas de eventos', 'notificaciones'),
|
||||||
|
# Cards / Analytics
|
||||||
|
('cards.view', 'Ver dashboard y analytics', 'cards'),
|
||||||
|
# Auditoría
|
||||||
|
('auditoria.view', 'Ver estado y resultados de auditoría VUCEM', 'auditoria'),
|
||||||
|
('auditoria.process', 'Lanzar procesos de auditoría y reauditoría', 'auditoria'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Conjuntos reutilizables para armar la matriz de permisos por rol
|
||||||
|
_IMPORTADORES_FULL = ['importadores.view', 'importadores.create', 'importadores.edit', 'importadores.delete']
|
||||||
|
_PEDIMENTOS_FULL = ['pedimentos.view', 'pedimentos.create', 'pedimentos.edit', 'pedimentos.delete', 'pedimentos.process']
|
||||||
|
_PARTIDAS_FULL = ['partidas.view', 'partidas.create', 'partidas.edit', 'partidas.delete']
|
||||||
|
_COVES_FULL = ['coves.view', 'coves.create', 'coves.edit', 'coves.delete']
|
||||||
|
_EDOCUMENTS_FULL = ['edocuments.view', 'edocuments.create', 'edocuments.edit', 'edocuments.delete']
|
||||||
|
_DOCUMENTOS_FULL = ['documentos.view', 'documentos.upload', 'documentos.download', 'documentos.delete']
|
||||||
|
_VUCEM_FULL = ['vucem.view', 'vucem.manage']
|
||||||
|
_REPORTES_FULL = ['reportes.view', 'reportes.export']
|
||||||
|
_DATASTAGE_FULL = ['datastage.view', 'datastage.create', 'datastage.process']
|
||||||
|
|
||||||
|
# --- ROLES POR DEFECTO ---
|
||||||
|
# Cada entrada: nombre → { descripcion, is_admin_role, permissions }
|
||||||
|
DEFAULT_ROLES = {
|
||||||
|
'admin': {
|
||||||
|
'descripcion': 'Administrador de la organización',
|
||||||
|
'is_admin_role': True,
|
||||||
|
'permissions': [
|
||||||
|
'usuarios.view', 'usuarios.create', 'usuarios.edit', 'usuarios.delete',
|
||||||
|
'usuarios.manage_roles', 'usuarios.change_password',
|
||||||
|
*_PEDIMENTOS_FULL, *_PARTIDAS_FULL, 'remesas.view',
|
||||||
|
*_COVES_FULL, *_EDOCUMENTS_FULL, 'acuses.view',
|
||||||
|
*_DOCUMENTOS_FULL, *_VUCEM_FULL,
|
||||||
|
*_IMPORTADORES_FULL,
|
||||||
|
*_REPORTES_FULL, *_DATASTAGE_FULL,
|
||||||
|
'organizacion.view', 'organizacion.edit',
|
||||||
|
'notificaciones.view', 'notificaciones.receive', 'cards.view',
|
||||||
|
'auditoria.view', 'auditoria.process',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'developer': {
|
||||||
|
'descripcion': 'Desarrollador con acceso técnico avanzado',
|
||||||
|
'is_admin_role': False,
|
||||||
|
'permissions': [
|
||||||
|
'usuarios.view', 'usuarios.create',
|
||||||
|
*_PEDIMENTOS_FULL, *_PARTIDAS_FULL, 'remesas.view',
|
||||||
|
*_COVES_FULL, *_EDOCUMENTS_FULL, 'acuses.view',
|
||||||
|
*_DOCUMENTOS_FULL, *_VUCEM_FULL, *_IMPORTADORES_FULL,
|
||||||
|
*_REPORTES_FULL, *_DATASTAGE_FULL,
|
||||||
|
'organizacion.view',
|
||||||
|
'notificaciones.view', 'notificaciones.receive', 'cards.view',
|
||||||
|
'auditoria.view', 'auditoria.process',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'Agente Aduanal': {
|
||||||
|
'descripcion': 'Agente aduanal operativo',
|
||||||
|
'is_admin_role': False,
|
||||||
|
'permissions': [
|
||||||
|
*_PEDIMENTOS_FULL, *_PARTIDAS_FULL, 'remesas.view',
|
||||||
|
*_COVES_FULL, *_EDOCUMENTS_FULL, 'acuses.view',
|
||||||
|
*_DOCUMENTOS_FULL, *_VUCEM_FULL,
|
||||||
|
*_REPORTES_FULL,
|
||||||
|
'organizacion.view',
|
||||||
|
'notificaciones.view', 'notificaciones.receive', 'cards.view',
|
||||||
|
'auditoria.view',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'user': {
|
||||||
|
'descripcion': 'Usuario básico de la organización',
|
||||||
|
'is_admin_role': False,
|
||||||
|
'permissions': [
|
||||||
|
'pedimentos.view', 'pedimentos.process',
|
||||||
|
'partidas.view', 'remesas.view',
|
||||||
|
'coves.view', 'edocuments.view', 'acuses.view',
|
||||||
|
'documentos.view', 'documentos.upload', 'documentos.download',
|
||||||
|
'reportes.view', 'datastage.view',
|
||||||
|
'notificaciones.view', 'notificaciones.receive', 'cards.view',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'Importador': {
|
||||||
|
'descripcion': 'Importador con acceso filtrado por RFC',
|
||||||
|
'is_admin_role': False,
|
||||||
|
'permissions': [
|
||||||
|
'pedimentos.view', 'partidas.view', 'remesas.view',
|
||||||
|
'coves.view', 'edocuments.view', 'acuses.view',
|
||||||
|
'documentos.view', 'documentos.download',
|
||||||
|
'vucem.view', 'vucem.manage',
|
||||||
|
'reportes.view',
|
||||||
|
'notificaciones.view', 'notificaciones.receive', 'cards.view',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def crear_roles_para_organizacion(organizacion):
|
||||||
|
"""Crea los 5 roles por defecto para una organización, con sus permisos.
|
||||||
|
Usa get_or_create — seguro de ejecutar múltiples veces."""
|
||||||
|
from api.rbac.models import RolePermission, OrganizationRole
|
||||||
|
|
||||||
|
perms_map = {p.codename: p for p in RolePermission.objects.all()}
|
||||||
|
|
||||||
|
for nombre, config in DEFAULT_ROLES.items():
|
||||||
|
role, created = OrganizationRole.objects.get_or_create(
|
||||||
|
organizacion=organizacion,
|
||||||
|
nombre=nombre,
|
||||||
|
defaults={
|
||||||
|
'descripcion': config['descripcion'],
|
||||||
|
'is_admin_role': config.get('is_admin_role', False),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
role_perms = [perms_map[c] for c in config['permissions'] if c in perms_map]
|
||||||
|
role.permissions.set(role_perms)
|
||||||
105
api/rbac/serializers.py
Normal file
105
api/rbac/serializers.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from api.rbac.models import OrganizationRole, RolePermission, UserPermission, UserRole
|
||||||
|
|
||||||
|
|
||||||
|
class RolePermissionSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = RolePermission
|
||||||
|
fields = ['id', 'codename', 'descripcion', 'modulo']
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationRoleSerializer(serializers.ModelSerializer):
|
||||||
|
permissions = RolePermissionSerializer(many=True, read_only=True)
|
||||||
|
permission_ids = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=RolePermission.objects.all(),
|
||||||
|
many=True,
|
||||||
|
write_only=True,
|
||||||
|
source='permissions',
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
user_count = serializers.IntegerField(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = OrganizationRole
|
||||||
|
fields = [
|
||||||
|
'id', 'nombre', 'descripcion', 'is_admin_role',
|
||||||
|
'permissions', 'permission_ids', 'user_count',
|
||||||
|
'created_at', 'updated_at',
|
||||||
|
]
|
||||||
|
read_only_fields = ['id', 'is_admin_role', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationRoleWriteSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serializer para crear/editar roles — recibe lista de IDs de permisos."""
|
||||||
|
permission_ids = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=RolePermission.objects.all(),
|
||||||
|
many=True,
|
||||||
|
source='permissions',
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = OrganizationRole
|
||||||
|
fields = ['nombre', 'descripcion', 'permission_ids']
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
perms = validated_data.pop('permissions', [])
|
||||||
|
role = OrganizationRole.objects.create(**validated_data)
|
||||||
|
role.permissions.set(perms)
|
||||||
|
return role
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
perms = validated_data.pop('permissions', None)
|
||||||
|
for attr, value in validated_data.items():
|
||||||
|
setattr(instance, attr, value)
|
||||||
|
instance.save()
|
||||||
|
if perms is not None:
|
||||||
|
instance.permissions.set(perms)
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
class _UserMinimalSerializer(serializers.Serializer):
|
||||||
|
id = serializers.UUIDField()
|
||||||
|
username = serializers.CharField()
|
||||||
|
email = serializers.EmailField()
|
||||||
|
first_name = serializers.CharField()
|
||||||
|
last_name = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
|
class _RoleMinimalSerializer(serializers.Serializer):
|
||||||
|
id = serializers.UUIDField()
|
||||||
|
nombre = serializers.CharField()
|
||||||
|
descripcion = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
|
class UserRoleSerializer(serializers.ModelSerializer):
|
||||||
|
user = _UserMinimalSerializer(read_only=True)
|
||||||
|
role = _RoleMinimalSerializer(read_only=True)
|
||||||
|
# write
|
||||||
|
user_id = serializers.UUIDField(write_only=True, source='user')
|
||||||
|
role_id = serializers.UUIDField(write_only=True, source='role')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = UserRole
|
||||||
|
fields = ['id', 'user', 'user_id', 'role', 'role_id', 'created_at']
|
||||||
|
read_only_fields = ['id', 'created_at']
|
||||||
|
|
||||||
|
|
||||||
|
class UserPermissionSerializer(serializers.ModelSerializer):
|
||||||
|
user = _UserMinimalSerializer(read_only=True)
|
||||||
|
permission = RolePermissionSerializer(read_only=True)
|
||||||
|
# write
|
||||||
|
user_id = serializers.UUIDField(write_only=True, source='user')
|
||||||
|
permission_id = serializers.IntegerField(write_only=True, source='permission')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = UserPermission
|
||||||
|
fields = ['id', 'user', 'user_id', 'permission', 'permission_id', 'granted', 'created_at']
|
||||||
|
read_only_fields = ['id', 'created_at']
|
||||||
|
|
||||||
|
|
||||||
|
class MyPermissionsSerializer(serializers.Serializer):
|
||||||
|
"""Respuesta de /rbac/my-permissions/ — permisos efectivos del usuario autenticado."""
|
||||||
|
permissions = serializers.ListField(child=serializers.CharField())
|
||||||
|
roles = serializers.ListField(child=serializers.CharField())
|
||||||
23
api/rbac/urls.py
Normal file
23
api/rbac/urls.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from django.urls import include, path
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
|
from api.rbac.views import (
|
||||||
|
MyPermissionsView,
|
||||||
|
OrganizationRoleViewSet,
|
||||||
|
RolePermissionViewSet,
|
||||||
|
SwitchOrganizationView,
|
||||||
|
UserPermissionViewSet,
|
||||||
|
UserRoleViewSet,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r'permissions', RolePermissionViewSet, basename='rbac-permission')
|
||||||
|
router.register(r'roles', OrganizationRoleViewSet, basename='rbac-role')
|
||||||
|
router.register(r'user-roles', UserRoleViewSet, basename='rbac-user-role')
|
||||||
|
router.register(r'user-permissions', UserPermissionViewSet, basename='rbac-user-permission')
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('', include(router.urls)),
|
||||||
|
path('my-permissions/', MyPermissionsView.as_view(), name='rbac-my-permissions'),
|
||||||
|
path('switch-organization/', SwitchOrganizationView.as_view(), name='rbac-switch-org'),
|
||||||
|
]
|
||||||
412
api/rbac/views.py
Normal file
412
api/rbac/views.py
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
from django.db.models import Count
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
|
||||||
|
|
||||||
|
from api.rbac.models import OrganizationRole, RolePermission, UserPermission, UserRole
|
||||||
|
from api.rbac.serializers import (
|
||||||
|
MyPermissionsSerializer,
|
||||||
|
OrganizationRoleSerializer,
|
||||||
|
OrganizationRoleWriteSerializer,
|
||||||
|
RolePermissionSerializer,
|
||||||
|
UserPermissionSerializer,
|
||||||
|
UserRoleSerializer,
|
||||||
|
)
|
||||||
|
from core.permissions import OrgScopedPermission, get_org_context, is_internal_service_request, require_permission, user_has_permission
|
||||||
|
|
||||||
|
|
||||||
|
def _require_manage_roles(user):
|
||||||
|
"""Retorna True si el usuario puede gestionar roles/permisos en su org."""
|
||||||
|
return user.is_superuser or user_has_permission(user, 'usuarios.manage_roles')
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Catálogo de permisos (lectura para todos los autenticados con org)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class RolePermissionViewSet(ReadOnlyModelViewSet):
|
||||||
|
"""Lista el catálogo global de permisos disponibles, agrupados por módulo."""
|
||||||
|
my_tags = ['RBAC']
|
||||||
|
serializer_class = RolePermissionSerializer
|
||||||
|
permission_classes = [IsAuthenticated, require_permission('usuarios.manage_roles')]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return RolePermission.objects.all().order_by('modulo', 'codename')
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'], url_path='by-module')
|
||||||
|
def by_module(self, request):
|
||||||
|
"""Devuelve el catálogo agrupado por módulo."""
|
||||||
|
perms = self.get_queryset()
|
||||||
|
result = {}
|
||||||
|
for p in perms:
|
||||||
|
result.setdefault(p.modulo, []).append(
|
||||||
|
RolePermissionSerializer(p).data
|
||||||
|
)
|
||||||
|
return Response(result)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Roles de la organización
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class OrganizationRoleViewSet(ModelViewSet):
|
||||||
|
"""
|
||||||
|
CRUD de roles de la organización activa.
|
||||||
|
Solo usuarios con usuarios.manage_roles pueden crear/editar/eliminar.
|
||||||
|
"""
|
||||||
|
my_tags = ['RBAC']
|
||||||
|
permission_classes = [IsAuthenticated, require_permission('usuarios.manage_roles')]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
if is_internal_service_request(self.request):
|
||||||
|
return (
|
||||||
|
OrganizationRole.objects
|
||||||
|
.annotate(user_count=Count('user_roles'))
|
||||||
|
.prefetch_related('permissions')
|
||||||
|
.order_by('nombre')
|
||||||
|
)
|
||||||
|
org = get_org_context(self.request.user)
|
||||||
|
if not org:
|
||||||
|
return OrganizationRole.objects.none()
|
||||||
|
return (
|
||||||
|
OrganizationRole.objects
|
||||||
|
.filter(organizacion=org)
|
||||||
|
.annotate(user_count=Count('user_roles'))
|
||||||
|
.prefetch_related('permissions')
|
||||||
|
.order_by('nombre')
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
if self.action in ('create', 'update', 'partial_update'):
|
||||||
|
return OrganizationRoleWriteSerializer
|
||||||
|
return OrganizationRoleSerializer
|
||||||
|
|
||||||
|
def _check_manage_roles(self):
|
||||||
|
if not _require_manage_roles(self.request.user):
|
||||||
|
return Response(
|
||||||
|
{'detail': 'Se requiere el permiso usuarios.manage_roles.'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def create(self, request, *args, **kwargs):
|
||||||
|
err = self._check_manage_roles()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
org = get_org_context(request.user)
|
||||||
|
if not org:
|
||||||
|
return Response({'detail': 'Sin organización activa.'}, status=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
serializer = self.get_serializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
serializer.save(organizacion=org)
|
||||||
|
return Response(
|
||||||
|
OrganizationRoleSerializer(serializer.instance).data,
|
||||||
|
status=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
|
||||||
|
def update(self, request, *args, **kwargs):
|
||||||
|
err = self._check_manage_roles()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
instance = self.get_object()
|
||||||
|
# No se puede cambiar nombre ni permisos de un rol is_admin_role
|
||||||
|
if instance.is_admin_role and not request.user.is_superuser:
|
||||||
|
return Response(
|
||||||
|
{'detail': 'No se puede modificar un rol de administrador.'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
return super().update(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def destroy(self, request, *args, **kwargs):
|
||||||
|
err = self._check_manage_roles()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
instance = self.get_object()
|
||||||
|
if instance.is_admin_role and not request.user.is_superuser:
|
||||||
|
return Response(
|
||||||
|
{'detail': 'No se puede eliminar un rol de administrador.'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
if instance.user_roles.exists():
|
||||||
|
return Response(
|
||||||
|
{'detail': 'No se puede eliminar un rol con usuarios asignados.'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
return super().destroy(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Asignación de roles a usuarios
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class UserRoleViewSet(ModelViewSet):
|
||||||
|
"""
|
||||||
|
Asigna y revoca roles de usuarios en la organización activa.
|
||||||
|
Solo usuarios con usuarios.manage_roles pueden modificar.
|
||||||
|
"""
|
||||||
|
my_tags = ['RBAC']
|
||||||
|
serializer_class = UserRoleSerializer
|
||||||
|
permission_classes = [IsAuthenticated, require_permission('usuarios.manage_roles')]
|
||||||
|
http_method_names = ['get', 'post', 'delete', 'head', 'options']
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
if is_internal_service_request(self.request):
|
||||||
|
qs = UserRole.objects.select_related('user', 'role')
|
||||||
|
user_id = self.request.query_params.get('user_id')
|
||||||
|
if user_id:
|
||||||
|
qs = qs.filter(user_id=user_id)
|
||||||
|
return qs
|
||||||
|
org = get_org_context(self.request.user)
|
||||||
|
if not org:
|
||||||
|
return UserRole.objects.none()
|
||||||
|
qs = (
|
||||||
|
UserRole.objects
|
||||||
|
.filter(role__organizacion=org)
|
||||||
|
.select_related('user', 'role')
|
||||||
|
)
|
||||||
|
user_id = self.request.query_params.get('user_id')
|
||||||
|
if user_id:
|
||||||
|
qs = qs.filter(user_id=user_id)
|
||||||
|
return qs
|
||||||
|
|
||||||
|
def create(self, request, *args, **kwargs):
|
||||||
|
if not _require_manage_roles(request.user):
|
||||||
|
return Response(
|
||||||
|
{'detail': 'Se requiere el permiso usuarios.manage_roles.'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
org = get_org_context(request.user)
|
||||||
|
if not org:
|
||||||
|
return Response({'detail': 'Sin organización activa.'}, status=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
user_id = request.data.get('user_id')
|
||||||
|
role_id = request.data.get('role_id')
|
||||||
|
|
||||||
|
if not user_id or not role_id:
|
||||||
|
return Response({'detail': 'user_id y role_id son requeridos.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Verificar que el rol pertenece a la misma org
|
||||||
|
try:
|
||||||
|
role = OrganizationRole.objects.get(id=role_id, organizacion=org)
|
||||||
|
except OrganizationRole.DoesNotExist:
|
||||||
|
return Response({'detail': 'El rol no pertenece a esta organización.'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
# Verificar que el usuario pertenece a la misma org
|
||||||
|
from api.cuser.models import CustomUser
|
||||||
|
try:
|
||||||
|
target_user = CustomUser.objects.get(id=user_id, organizacion=org)
|
||||||
|
except CustomUser.DoesNotExist:
|
||||||
|
return Response({'detail': 'El usuario no pertenece a esta organización.'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
user_role, created = UserRole.objects.get_or_create(user=target_user, role=role)
|
||||||
|
serializer = self.get_serializer(user_role)
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK)
|
||||||
|
|
||||||
|
def destroy(self, request, *args, **kwargs):
|
||||||
|
if not _require_manage_roles(request.user):
|
||||||
|
return Response(
|
||||||
|
{'detail': 'Se requiere el permiso usuarios.manage_roles.'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
instance = self.get_object()
|
||||||
|
org = get_org_context(request.user)
|
||||||
|
|
||||||
|
# Proteger al owner de la org: no se le puede quitar el rol admin
|
||||||
|
if org and hasattr(org, 'owner') and org.owner and instance.user == org.owner:
|
||||||
|
if instance.role.is_admin_role:
|
||||||
|
return Response(
|
||||||
|
{'detail': 'No se puede revocar el rol de administrador al propietario de la organización.'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
return super().destroy(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Permisos singulares (overrides por usuario)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class UserPermissionViewSet(ModelViewSet):
|
||||||
|
"""
|
||||||
|
Otorga o deniega permisos singulares a usuarios, sin necesidad de crear un rol.
|
||||||
|
granted=true → otorgar; granted=false → denegar explícitamente (override sobre roles).
|
||||||
|
Solo usuarios con usuarios.manage_roles pueden modificar.
|
||||||
|
"""
|
||||||
|
my_tags = ['RBAC']
|
||||||
|
serializer_class = UserPermissionSerializer
|
||||||
|
permission_classes = [IsAuthenticated, require_permission('usuarios.manage_roles')]
|
||||||
|
http_method_names = ['get', 'post', 'patch', 'delete', 'head', 'options']
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
if is_internal_service_request(self.request):
|
||||||
|
qs = UserPermission.objects.select_related('user', 'permission')
|
||||||
|
user_id = self.request.query_params.get('user_id')
|
||||||
|
if user_id:
|
||||||
|
qs = qs.filter(user_id=user_id)
|
||||||
|
return qs
|
||||||
|
org = get_org_context(self.request.user)
|
||||||
|
if not org:
|
||||||
|
return UserPermission.objects.none()
|
||||||
|
qs = (
|
||||||
|
UserPermission.objects
|
||||||
|
.filter(user__organizacion=org)
|
||||||
|
.select_related('user', 'permission')
|
||||||
|
)
|
||||||
|
user_id = self.request.query_params.get('user_id')
|
||||||
|
if user_id:
|
||||||
|
qs = qs.filter(user_id=user_id)
|
||||||
|
return qs
|
||||||
|
|
||||||
|
def _check(self):
|
||||||
|
if not _require_manage_roles(self.request.user):
|
||||||
|
return Response(
|
||||||
|
{'detail': 'Se requiere el permiso usuarios.manage_roles.'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def create(self, request, *args, **kwargs):
|
||||||
|
err = self._check()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
org = get_org_context(request.user)
|
||||||
|
if not org:
|
||||||
|
return Response({'detail': 'Sin organización activa.'}, status=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
user_id = request.data.get('user_id')
|
||||||
|
permission_id = request.data.get('permission_id')
|
||||||
|
granted = request.data.get('granted', True)
|
||||||
|
|
||||||
|
if not user_id or not permission_id:
|
||||||
|
return Response({'detail': 'user_id y permission_id son requeridos.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
from api.cuser.models import CustomUser
|
||||||
|
try:
|
||||||
|
target_user = CustomUser.objects.get(id=user_id, organizacion=org)
|
||||||
|
except CustomUser.DoesNotExist:
|
||||||
|
return Response({'detail': 'El usuario no pertenece a esta organización.'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
try:
|
||||||
|
perm = RolePermission.objects.get(id=permission_id)
|
||||||
|
except RolePermission.DoesNotExist:
|
||||||
|
return Response({'detail': 'Permiso no encontrado.'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
override, created = UserPermission.objects.update_or_create(
|
||||||
|
user=target_user,
|
||||||
|
permission=perm,
|
||||||
|
defaults={'granted': granted},
|
||||||
|
)
|
||||||
|
serializer = self.get_serializer(override)
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK)
|
||||||
|
|
||||||
|
def partial_update(self, request, *args, **kwargs):
|
||||||
|
err = self._check()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
return super().partial_update(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def destroy(self, request, *args, **kwargs):
|
||||||
|
err = self._check()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
return super().destroy(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Mis permisos efectivos (para el frontend)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class MyPermissionsView(APIView):
|
||||||
|
"""
|
||||||
|
Retorna los permisos efectivos del usuario autenticado.
|
||||||
|
El frontend usa esto para decidir qué mostrar/ocultar.
|
||||||
|
"""
|
||||||
|
my_tags = ['RBAC']
|
||||||
|
permission_classes = [IsAuthenticated & OrgScopedPermission]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
user = request.user
|
||||||
|
org = get_org_context(user)
|
||||||
|
|
||||||
|
if user.is_superuser:
|
||||||
|
all_perms = list(RolePermission.objects.values_list('codename', flat=True))
|
||||||
|
return Response({'permissions': all_perms, 'roles': ['superuser']})
|
||||||
|
|
||||||
|
if not org:
|
||||||
|
return Response({'permissions': [], 'roles': []})
|
||||||
|
|
||||||
|
# Roles del usuario en la org
|
||||||
|
roles = list(
|
||||||
|
UserRole.objects.filter(user=user, role__organizacion=org)
|
||||||
|
.values_list('role__nombre', flat=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Permisos de roles
|
||||||
|
perms_set = set(
|
||||||
|
UserRole.objects.filter(user=user, role__organizacion=org)
|
||||||
|
.values_list('role__permissions__codename', flat=True)
|
||||||
|
)
|
||||||
|
perms_set.discard(None)
|
||||||
|
|
||||||
|
# Aplicar overrides singulares
|
||||||
|
for override in UserPermission.objects.filter(user=user).select_related('permission'):
|
||||||
|
if override.granted:
|
||||||
|
perms_set.add(override.permission.codename)
|
||||||
|
else:
|
||||||
|
perms_set.discard(override.permission.codename)
|
||||||
|
|
||||||
|
return Response({'permissions': sorted(perms_set), 'roles': roles})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Switch de organización (solo superusuarios)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class SwitchOrganizationView(APIView):
|
||||||
|
"""
|
||||||
|
Permite a un superusuario cambiar su organización activa.
|
||||||
|
POST { "organization_id": "<uuid>" } → actualiza active_organization del superuser.
|
||||||
|
DELETE → limpia active_organization (el superuser queda sin contexto de org).
|
||||||
|
"""
|
||||||
|
my_tags = ['RBAC']
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
if not request.user.is_superuser:
|
||||||
|
return Response(
|
||||||
|
{'detail': 'Solo superusuarios pueden cambiar de organización.'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
|
||||||
|
org_id = request.data.get('organization_id')
|
||||||
|
if not org_id:
|
||||||
|
return Response({'detail': 'organization_id es requerido.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
from api.organization.models import Organizacion
|
||||||
|
try:
|
||||||
|
import uuid as _uuid
|
||||||
|
org = Organizacion.objects.get(id=_uuid.UUID(str(org_id)))
|
||||||
|
except (Organizacion.DoesNotExist, ValueError):
|
||||||
|
return Response({'detail': 'Organización no encontrada.'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
request.user.active_organization = org
|
||||||
|
request.user.save(update_fields=['active_organization'])
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'detail': f'Organización activa actualizada a: {org.nombre}',
|
||||||
|
'organization': {'id': str(org.id), 'nombre': org.nombre},
|
||||||
|
})
|
||||||
|
|
||||||
|
def delete(self, request):
|
||||||
|
if not request.user.is_superuser:
|
||||||
|
return Response(
|
||||||
|
{'detail': 'Solo superusuarios pueden limpiar la organización activa.'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
|
||||||
|
request.user.active_organization = None
|
||||||
|
request.user.save(update_fields=['active_organization'])
|
||||||
|
return Response({'detail': 'Organización activa removida.'})
|
||||||
18
api/record/migrations/0003_document_vu.py
Normal file
18
api/record/migrations/0003_document_vu.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.3 on 2026-03-06 19:35
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('record', '0002_fuente_document_fuente'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='document',
|
||||||
|
name='vu',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
||||||
30
api/record/migrations/0004_document_subentidad_fk.py
Normal file
30
api/record/migrations/0004_document_subentidad_fk.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Generated by Django 5.2.3 on 2026-06-24 13:37
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('customs', '0020_estados_descarga_t2026_05_027'),
|
||||||
|
('record', '0003_document_vu'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='document',
|
||||||
|
name='cove',
|
||||||
|
field=models.ForeignKey(blank=True, db_index=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='customs.cove'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='document',
|
||||||
|
name='edocument',
|
||||||
|
field=models.ForeignKey(blank=True, db_index=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='customs.edocument'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='document',
|
||||||
|
name='partida',
|
||||||
|
field=models.ForeignKey(blank=True, db_index=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='customs.partida'),
|
||||||
|
),
|
||||||
|
]
|
||||||
55
api/record/migrations/0005_document_subentidad_idx.py
Normal file
55
api/record/migrations/0005_document_subentidad_idx.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Índices de las FK de sub-entidad en la tabla `document` (grande: ~5M filas en
|
||||||
|
# prod) con CREATE INDEX CONCURRENTLY para no bloquear escrituras.
|
||||||
|
#
|
||||||
|
# CONCURRENTLY no corre dentro de transacción (atomic=False) y NO es transaccional:
|
||||||
|
# si el proceso muere a mitad puede dejar un índice a medias. Por eso:
|
||||||
|
# - IF NOT EXISTS → el reintento es idempotente (no choca con "already exists").
|
||||||
|
# - SeparateDatabaseAndState → el índice se refleja en el estado del modelo
|
||||||
|
# (AddIndex) sin que Django intente recrearlo, manteniendo el estado consistente.
|
||||||
|
#
|
||||||
|
# Recuperación si un build quedó INVALID (kill DURANTE la construcción, no después):
|
||||||
|
# DROP INDEX IF EXISTS "<nombre>"; y reintentar python manage.py migrate record
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
atomic = False
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('record', '0004_document_subentidad_fk'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.SeparateDatabaseAndState(
|
||||||
|
database_operations=[migrations.RunSQL(
|
||||||
|
sql='CREATE INDEX CONCURRENTLY IF NOT EXISTS "document_partida_idx" ON "document" ("partida_id");',
|
||||||
|
reverse_sql='DROP INDEX IF EXISTS "document_partida_idx";',
|
||||||
|
)],
|
||||||
|
state_operations=[migrations.AddIndex(
|
||||||
|
model_name='document',
|
||||||
|
index=models.Index(fields=['partida'], name='document_partida_idx'),
|
||||||
|
)],
|
||||||
|
),
|
||||||
|
migrations.SeparateDatabaseAndState(
|
||||||
|
database_operations=[migrations.RunSQL(
|
||||||
|
sql='CREATE INDEX CONCURRENTLY IF NOT EXISTS "document_cove_idx" ON "document" ("cove_id");',
|
||||||
|
reverse_sql='DROP INDEX IF EXISTS "document_cove_idx";',
|
||||||
|
)],
|
||||||
|
state_operations=[migrations.AddIndex(
|
||||||
|
model_name='document',
|
||||||
|
index=models.Index(fields=['cove'], name='document_cove_idx'),
|
||||||
|
)],
|
||||||
|
),
|
||||||
|
migrations.SeparateDatabaseAndState(
|
||||||
|
database_operations=[migrations.RunSQL(
|
||||||
|
sql='CREATE INDEX CONCURRENTLY IF NOT EXISTS "document_edocument_idx" ON "document" ("edocument_id");',
|
||||||
|
reverse_sql='DROP INDEX IF EXISTS "document_edocument_idx";',
|
||||||
|
)],
|
||||||
|
state_operations=[migrations.AddIndex(
|
||||||
|
model_name='document',
|
||||||
|
index=models.Index(fields=['edocument'], name='document_edocument_idx'),
|
||||||
|
)],
|
||||||
|
),
|
||||||
|
]
|
||||||
22
api/record/migrations/0006_analyze_document.py
Normal file
22
api/record/migrations/0006_analyze_document.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Recalcula estadísticas del planner tras crear los índices de las FK (0005).
|
||||||
|
# CREATE INDEX CONCURRENTLY NO corre ANALYZE: sin estadísticas frescas de las
|
||||||
|
# columnas nuevas (partida_id/cove_id/edocument_id, casi todas NULL antes del
|
||||||
|
# backfill), el planner puede elegir un seq scan sobre la tabla `document` (~5M
|
||||||
|
# filas) para las consultas del prefetch en vez de usar el índice → endpoints
|
||||||
|
# muy lentos. Este ANALYZE lo previene en cada entorno.
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('record', '0005_document_subentidad_idx'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunSQL(
|
||||||
|
sql='ANALYZE "document";',
|
||||||
|
reverse_sql=migrations.RunSQL.noop,
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -17,6 +17,14 @@ class Document(models.Model):
|
|||||||
fuente = models.ForeignKey('Fuente', on_delete=models.CASCADE, related_name='documents', blank=True, null=True)
|
fuente = models.ForeignKey('Fuente', on_delete=models.CASCADE, related_name='documents', blank=True, null=True)
|
||||||
vu = models.BooleanField(default=False)
|
vu = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
# Sub-entidad a la que pertenece el documento (None para docs nativos del
|
||||||
|
# pedimento: PC, remesa, subidas generales). Se puebla por nombre de archivo
|
||||||
|
# en save() vía core.document_links. db_index=False: el índice lo crea una
|
||||||
|
# migración aparte con CREATE INDEX CONCURRENTLY (tabla grande en prod).
|
||||||
|
partida = models.ForeignKey('customs.Partida', on_delete=models.CASCADE, related_name='documents', blank=True, null=True, db_index=False)
|
||||||
|
cove = models.ForeignKey('customs.Cove', on_delete=models.CASCADE, related_name='documents', blank=True, null=True, db_index=False)
|
||||||
|
edocument = models.ForeignKey('customs.EDocument', on_delete=models.CASCADE, related_name='documents', blank=True, null=True, db_index=False)
|
||||||
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
@@ -30,6 +38,17 @@ class Document(models.Model):
|
|||||||
else:
|
else:
|
||||||
self.vu = False
|
self.vu = False
|
||||||
|
|
||||||
|
# Ligar la sub-entidad (partida/cove/edocument) por nombre de archivo si
|
||||||
|
# aún no está ligada. Cubre todas las rutas de creación —incluida la
|
||||||
|
# ingesta del microservicio— sin tocar cada call site. Se ejecuta también
|
||||||
|
# en update porque el patrón común es create() sin archivo y luego
|
||||||
|
# asignar archivo + save(). Fuente única: core.document_links.
|
||||||
|
if self.archivo and not (self.partida_id or self.cove_id or self.edocument_id):
|
||||||
|
from core.document_links import resolver_fk
|
||||||
|
campo, inst = resolver_fk(self)
|
||||||
|
if inst is not None:
|
||||||
|
setattr(self, campo, inst)
|
||||||
|
|
||||||
# Usar get_or_create en lugar de get para manejar el caso cuando no existe
|
# Usar get_or_create en lugar de get para manejar el caso cuando no existe
|
||||||
uso_almacenamiento, created = UsoAlmacenamiento.objects.get_or_create(
|
uso_almacenamiento, created = UsoAlmacenamiento.objects.get_or_create(
|
||||||
organizacion=self.organizacion,
|
organizacion=self.organizacion,
|
||||||
@@ -77,6 +96,14 @@ class Document(models.Model):
|
|||||||
verbose_name_plural = "Documents"
|
verbose_name_plural = "Documents"
|
||||||
db_table = 'document'
|
db_table = 'document'
|
||||||
ordering = ['created_at']
|
ordering = ['created_at']
|
||||||
|
# Índices de las FK de sub-entidad. Se crean con CREATE INDEX CONCURRENTLY
|
||||||
|
# en una migración aparte (atomic=False); por eso los campos usan
|
||||||
|
# db_index=False y el índice se declara aquí.
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['partida'], name='document_partida_idx'),
|
||||||
|
models.Index(fields=['cove'], name='document_cove_idx'),
|
||||||
|
models.Index(fields=['edocument'], name='document_edocument_idx'),
|
||||||
|
]
|
||||||
|
|
||||||
class DocumentType(models.Model):
|
class DocumentType(models.Model):
|
||||||
nombre = models.CharField(max_length=100, unique=True)
|
nombre = models.CharField(max_length=100, unique=True)
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ class DocumentSerializer(serializers.ModelSerializer):
|
|||||||
read_only_fields = ('id', 'size', 'extension', 'created_at', 'updated_at', 'pedimento_numero')
|
read_only_fields = ('id', 'size', 'extension', 'created_at', 'updated_at', 'pedimento_numero')
|
||||||
|
|
||||||
def get_pedimento_numero(self, obj):
|
def get_pedimento_numero(self, obj):
|
||||||
|
# Si es un diccionario (durante create)
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
pedimento = obj.get('pedimento')
|
||||||
|
if pedimento and hasattr(pedimento, 'pedimento_app'):
|
||||||
|
return pedimento.pedimento_app
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Si es una instancia del modelo (durante retrieve/list)
|
||||||
if obj.pedimento:
|
if obj.pedimento:
|
||||||
return obj.pedimento.pedimento_app
|
return obj.pedimento.pedimento_app
|
||||||
return None
|
return None
|
||||||
@@ -28,9 +36,19 @@ class DocumentSerializer(serializers.ModelSerializer):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
def get_fuente_nombre(self, obj):
|
def get_fuente_nombre(self, obj):
|
||||||
# Método 1: Si la fuente está precargada con select_related
|
"""Obtiene el nombre de la fuente de forma segura"""
|
||||||
if obj.fuente:
|
if isinstance(obj, dict):
|
||||||
return obj.fuente.nombre
|
fuente = obj.get('fuente')
|
||||||
|
if fuente and hasattr(fuente, 'nombre'):
|
||||||
|
return fuente.nombre
|
||||||
|
return "Desconocido"
|
||||||
|
|
||||||
|
try:
|
||||||
|
if obj.fuente:
|
||||||
|
return obj.fuente.nombre
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
|
||||||
return "Desconocido"
|
return "Desconocido"
|
||||||
|
|
||||||
class FuenteSerializer(serializers.ModelSerializer):
|
class FuenteSerializer(serializers.ModelSerializer):
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.test import TestCase
|
||||||
from rest_framework.test import APITestCase, APIClient
|
from rest_framework.test import APITestCase, APIClient
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
from api.organization.models import Organizacion, UsoAlmacenamiento
|
from api.organization.models import Organizacion, UsoAlmacenamiento
|
||||||
from api.cuser.models import CustomUser
|
from api.cuser.models import CustomUser
|
||||||
from api.customs.models import Pedimento
|
from api.customs.models import Pedimento
|
||||||
from .models import Document
|
from api.licence.models import Licencia
|
||||||
|
from api.customs.views import is_same_document, get_clean_base_filename
|
||||||
|
from .models import Document, DocumentType
|
||||||
import io
|
import io
|
||||||
|
|
||||||
class DocumentViewSetTests(APITestCase):
|
class DocumentViewSetTests(APITestCase):
|
||||||
@@ -95,3 +99,177 @@ class DocumentViewSetTests(APITestCase):
|
|||||||
url = reverse('descargar-documento', args=[doc.id])
|
url = reverse('descargar-documento', args=[doc.id])
|
||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests unitarios para las funciones helper de comparación de documentos
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class DocumentNameHelperTests(TestCase):
|
||||||
|
"""Verifica que get_clean_base_filename e is_same_document manejan
|
||||||
|
correctamente el sufijo UUID de 8 chars que añade storage_service."""
|
||||||
|
|
||||||
|
def test_strips_uuid_suffix(self):
|
||||||
|
self.assertEqual(get_clean_base_filename('informe_a1b2c3d4.pdf'), 'informe')
|
||||||
|
|
||||||
|
def test_no_suffix_unchanged(self):
|
||||||
|
self.assertEqual(get_clean_base_filename('informe.pdf'), 'informe')
|
||||||
|
|
||||||
|
def test_is_same_document_matches_stored_uuid_name(self):
|
||||||
|
"""El archivo guardado tiene sufijo, el nuevo no — deben coincidir."""
|
||||||
|
doc = MagicMock()
|
||||||
|
doc.archivo.name = 'org_1/documents/24-01-3420-1234567/informe_a1b2c3d4.pdf'
|
||||||
|
doc.extension = 'pdf'
|
||||||
|
self.assertTrue(is_same_document(doc, 'informe.pdf'))
|
||||||
|
|
||||||
|
def test_is_same_document_different_name_no_match(self):
|
||||||
|
doc = MagicMock()
|
||||||
|
doc.archivo.name = 'org_1/documents/ped/informe_a1b2c3d4.pdf'
|
||||||
|
doc.extension = 'pdf'
|
||||||
|
self.assertFalse(is_same_document(doc, 'otro.pdf'))
|
||||||
|
|
||||||
|
def test_is_same_document_different_extension_no_match(self):
|
||||||
|
doc = MagicMock()
|
||||||
|
doc.archivo.name = 'org_1/documents/ped/informe_a1b2c3d4.pdf'
|
||||||
|
doc.extension = 'pdf'
|
||||||
|
self.assertFalse(is_same_document(doc, 'informe.xml'))
|
||||||
|
|
||||||
|
def test_both_clean_names_equal(self):
|
||||||
|
"""Dos archivos con UUID distintos pero mismo nombre base deben coincidir."""
|
||||||
|
doc = MagicMock()
|
||||||
|
doc.archivo.name = 'org_1/documents/ped/pedimento_a1b2c3d4.xml'
|
||||||
|
doc.extension = 'xml'
|
||||||
|
self.assertTrue(is_same_document(doc, 'pedimento_b5c6d7e8.xml'))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests de integración para bulk-upload (DocumentViewSet.bulk_upload)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class BulkUploadReplaceTests(APITestCase):
|
||||||
|
"""Verifica que bulk-upload reemplaza documentos existentes en vez de duplicar
|
||||||
|
y que no quedan archivos residuales en el storage."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.licencia = Licencia.objects.create(nombre="Lic100GB", almacenamiento=100)
|
||||||
|
self.org = Organizacion.objects.create(
|
||||||
|
nombre="OrgBulkUpload",
|
||||||
|
licencia=self.licencia,
|
||||||
|
is_active=True,
|
||||||
|
is_verified=True,
|
||||||
|
)
|
||||||
|
self.user = CustomUser.objects.create_user(
|
||||||
|
username="bulkuploaduser", password="pass", organizacion=self.org
|
||||||
|
)
|
||||||
|
self.pedimento = Pedimento.objects.create(
|
||||||
|
organizacion=self.org,
|
||||||
|
pedimento="1234567",
|
||||||
|
pedimento_app="24-01-3420-1234567",
|
||||||
|
)
|
||||||
|
self.doc_type = DocumentType.objects.get_or_create(nombre="Documento General")[0]
|
||||||
|
self.url = reverse("Document-bulk-upload")
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
|
def _post_file(self, filename, content=b"contenido de prueba"):
|
||||||
|
archivo = SimpleUploadedFile(filename, content, content_type="application/pdf")
|
||||||
|
return self.client.post(
|
||||||
|
self.url,
|
||||||
|
{"pedimento_id": str(self.pedimento.id), "files": [archivo]},
|
||||||
|
format="multipart",
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("api.record.views.storage_service")
|
||||||
|
def test_new_file_creates_document(self, mock_st):
|
||||||
|
"""Subir un archivo nuevo crea exactamente un Document."""
|
||||||
|
mock_st.save_document.return_value = "org_1/documents/ped/informe_a1b2c3d4.pdf"
|
||||||
|
|
||||||
|
response = self._post_file("informe.pdf")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
self.assertEqual(Document.objects.filter(pedimento=self.pedimento).count(), 1)
|
||||||
|
mock_st.delete_file.assert_not_called()
|
||||||
|
|
||||||
|
@patch("api.record.views.storage_service")
|
||||||
|
def test_duplicate_replaces_not_creates(self, mock_st):
|
||||||
|
"""Re-subir el mismo archivo debe actualizar el Document existente,
|
||||||
|
no crear uno nuevo."""
|
||||||
|
old_path = "org_1/documents/24-01-3420-1234567/informe_a1b2c3d4.pdf"
|
||||||
|
old_doc = Document.objects.create(
|
||||||
|
organizacion=self.org,
|
||||||
|
pedimento=self.pedimento,
|
||||||
|
document_type=self.doc_type,
|
||||||
|
archivo=old_path,
|
||||||
|
size=500,
|
||||||
|
extension="pdf",
|
||||||
|
)
|
||||||
|
new_path = "org_1/documents/24-01-3420-1234567/informe_b5c6d7e8.pdf"
|
||||||
|
mock_st.save_document.return_value = new_path
|
||||||
|
mock_st.delete_file.return_value = True
|
||||||
|
|
||||||
|
response = self._post_file("informe.pdf", b"contenido actualizado")
|
||||||
|
|
||||||
|
self.assertIn(response.status_code, [status.HTTP_201_CREATED, status.HTTP_207_MULTI_STATUS])
|
||||||
|
docs = Document.objects.filter(pedimento=self.pedimento)
|
||||||
|
# Un único Document — sin duplicados
|
||||||
|
self.assertEqual(docs.count(), 1)
|
||||||
|
# Es el mismo registro (mismo UUID)
|
||||||
|
self.assertEqual(docs.first().id, old_doc.id)
|
||||||
|
# El campo archivo fue actualizado
|
||||||
|
old_doc.refresh_from_db()
|
||||||
|
self.assertEqual(old_doc.archivo.name, new_path)
|
||||||
|
|
||||||
|
@patch("api.record.views.storage_service")
|
||||||
|
def test_replace_deletes_old_storage_file(self, mock_st):
|
||||||
|
"""Al reemplazar, delete_file debe llamarse con la ruta del archivo viejo."""
|
||||||
|
old_path = "org_1/documents/24-01-3420-1234567/informe_a1b2c3d4.pdf"
|
||||||
|
Document.objects.create(
|
||||||
|
organizacion=self.org,
|
||||||
|
pedimento=self.pedimento,
|
||||||
|
document_type=self.doc_type,
|
||||||
|
archivo=old_path,
|
||||||
|
size=500,
|
||||||
|
extension="pdf",
|
||||||
|
)
|
||||||
|
mock_st.save_document.return_value = "org_1/documents/24-01-3420-1234567/informe_b5c6d7e8.pdf"
|
||||||
|
mock_st.delete_file.return_value = True
|
||||||
|
|
||||||
|
self._post_file("informe.pdf")
|
||||||
|
|
||||||
|
mock_st.delete_file.assert_called_once_with(old_path)
|
||||||
|
|
||||||
|
@patch("api.record.views.storage_service")
|
||||||
|
def test_different_filename_creates_new_document(self, mock_st):
|
||||||
|
"""Archivo con nombre diferente debe crear un Document adicional."""
|
||||||
|
Document.objects.create(
|
||||||
|
organizacion=self.org,
|
||||||
|
pedimento=self.pedimento,
|
||||||
|
document_type=self.doc_type,
|
||||||
|
archivo="org_1/documents/ped/informe_a1b2c3d4.pdf",
|
||||||
|
size=500,
|
||||||
|
extension="pdf",
|
||||||
|
)
|
||||||
|
mock_st.save_document.return_value = "org_1/documents/ped/otro_b5c6d7e8.pdf"
|
||||||
|
|
||||||
|
self._post_file("otro.pdf")
|
||||||
|
|
||||||
|
self.assertEqual(Document.objects.filter(pedimento=self.pedimento).count(), 2)
|
||||||
|
mock_st.delete_file.assert_not_called()
|
||||||
|
|
||||||
|
@patch("api.record.views.storage_service")
|
||||||
|
def test_multiple_files_no_cross_replacement(self, mock_st):
|
||||||
|
"""Subir dos archivos distintos en la misma petición crea dos Documents."""
|
||||||
|
mock_st.save_document.side_effect = [
|
||||||
|
"org_1/documents/ped/a_a1b2c3d4.pdf",
|
||||||
|
"org_1/documents/ped/b_a1b2c3d4.pdf",
|
||||||
|
]
|
||||||
|
archivos = [
|
||||||
|
SimpleUploadedFile("a.pdf", b"contenido a", content_type="application/pdf"),
|
||||||
|
SimpleUploadedFile("b.pdf", b"contenido b", content_type="application/pdf"),
|
||||||
|
]
|
||||||
|
self.client.post(
|
||||||
|
self.url,
|
||||||
|
{"pedimento_id": str(self.pedimento.id), "files": archivos},
|
||||||
|
format="multipart",
|
||||||
|
)
|
||||||
|
self.assertEqual(Document.objects.filter(pedimento=self.pedimento).count(), 2)
|
||||||
|
mock_st.delete_file.assert_not_called()
|
||||||
|
|||||||
1544
api/record/views.py
1544
api/record/views.py
File diff suppressed because it is too large
Load Diff
18
api/reports/migrations/0002_reportdocument_report_type.py
Normal file
18
api/reports/migrations/0002_reportdocument_report_type.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.3 on 2025-11-21 14:40
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('reports', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='reportdocument',
|
||||||
|
name='report_type',
|
||||||
|
field=models.CharField(choices=[('cumplimiento', 'cumplimiento'), ('control_pedimento', 'control_pedimento')], default='cumplimiento', max_length=30),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
api/reports/migrations/0003_alter_reportdocument_file.py
Normal file
18
api/reports/migrations/0003_alter_reportdocument_file.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.3 on 2026-04-21 22:53
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('reports', '0002_reportdocument_report_type'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='reportdocument',
|
||||||
|
name='file',
|
||||||
|
field=models.CharField(blank=True, max_length=500, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -12,11 +12,13 @@ class ReportDocument(models.Model):
|
|||||||
TYPE_REPORT = [
|
TYPE_REPORT = [
|
||||||
('cumplimiento', 'cumplimiento'),
|
('cumplimiento', 'cumplimiento'),
|
||||||
('control_pedimento', 'control_pedimento'),
|
('control_pedimento', 'control_pedimento'),
|
||||||
|
('datastage', 'datastage'),
|
||||||
]
|
]
|
||||||
user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, related_name='report_documents')
|
user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, related_name='report_documents')
|
||||||
filters = models.JSONField(blank=True, null=True)
|
filters = models.JSONField(blank=True, null=True)
|
||||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
|
||||||
file = models.FileField(upload_to='reports/', blank=True, null=True)
|
# file = models.FileField(upload_to='reports/', blank=True, null=True)
|
||||||
|
file = models.CharField(max_length=500, blank=True, null=True)
|
||||||
report_type = models.CharField(max_length=30, choices=TYPE_REPORT, default='cumplimiento')
|
report_type = models.CharField(max_length=30, choices=TYPE_REPORT, default='cumplimiento')
|
||||||
error_message = models.TextField(blank=True, null=True)
|
error_message = models.TextField(blank=True, null=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|||||||
0
api/reports/services/__init__.py
Normal file
0
api/reports/services/__init__.py
Normal file
557
api/reports/services/datastage_export.py
Normal file
557
api/reports/services/datastage_export.py
Normal file
@@ -0,0 +1,557 @@
|
|||||||
|
"""
|
||||||
|
Lógica de exportación de reportes DataStage, extraída de ExportDataStageView
|
||||||
|
para poder ejecutarse dentro de una task Celery (sin request/HttpResponse).
|
||||||
|
|
||||||
|
Cada builder devuelve una tupla (content_bytes, filename, content_type, total_rows).
|
||||||
|
El aislamiento multi-tenant viene resuelto en global_filters['organizacion']
|
||||||
|
(la vista lo resuelve con get_org_context antes de encolar).
|
||||||
|
"""
|
||||||
|
import csv
|
||||||
|
import datetime
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import uuid
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
import openpyxl
|
||||||
|
from django.apps import apps
|
||||||
|
from django.core.paginator import Paginator
|
||||||
|
|
||||||
|
from api.organization.models import Organizacion
|
||||||
|
|
||||||
|
MAX_RECORDS_PER_FILE = 500000 # Límite por archivo Excel antes de particionar en ZIP
|
||||||
|
|
||||||
|
XLSX_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
CSV_CONTENT_TYPE = 'text/csv; charset=utf-8'
|
||||||
|
ZIP_CONTENT_TYPE = 'application/zip'
|
||||||
|
|
||||||
|
RELATION_FIELDS = ['seccion_aduanera', 'patente', 'pedimento']
|
||||||
|
|
||||||
|
|
||||||
|
def safe_excel_value(value):
|
||||||
|
"""Convierte cualquier valor a un formato seguro para Excel/CSV."""
|
||||||
|
if value is None:
|
||||||
|
return ''
|
||||||
|
elif isinstance(value, (uuid.UUID,)):
|
||||||
|
return str(value)
|
||||||
|
elif hasattr(value, 'uuid'):
|
||||||
|
return str(value.uuid)
|
||||||
|
elif hasattr(value, 'id'):
|
||||||
|
return str(value.id)
|
||||||
|
elif isinstance(value, (datetime.datetime, datetime.date)):
|
||||||
|
return value.isoformat()
|
||||||
|
elif isinstance(value, (dict, list)):
|
||||||
|
return str(value)
|
||||||
|
else:
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_global_filters_to_model(global_filters, model):
|
||||||
|
"""Traduce los filtros globales a filtros ORM según los campos del modelo."""
|
||||||
|
filters = {}
|
||||||
|
model_fields = [f.name for f in model._meta.get_fields()]
|
||||||
|
|
||||||
|
# Organización — FK usa UUID, CharField usa el string tal cual
|
||||||
|
org_value = global_filters.get('organizacion')
|
||||||
|
if org_value and org_value != '' and 'organizacion' in model_fields:
|
||||||
|
field = model._meta.get_field('organizacion')
|
||||||
|
if hasattr(field, 'related_model'):
|
||||||
|
try:
|
||||||
|
filters['organizacion_id'] = uuid.UUID(org_value)
|
||||||
|
except Exception:
|
||||||
|
filters['organizacion_id'] = org_value
|
||||||
|
else:
|
||||||
|
filters['organizacion'] = org_value
|
||||||
|
|
||||||
|
rfc_value = global_filters.get('rfc')
|
||||||
|
if rfc_value and rfc_value != '' and 'rfc' in model_fields:
|
||||||
|
filters['rfc'] = rfc_value
|
||||||
|
|
||||||
|
if global_filters.get('patente'):
|
||||||
|
filters['patente'] = global_filters['patente']
|
||||||
|
|
||||||
|
if global_filters.get('pedimento'):
|
||||||
|
filters['pedimento'] = global_filters['pedimento']
|
||||||
|
|
||||||
|
if 'fecha_pago_real' in model_fields:
|
||||||
|
if global_filters.get('fecha_pago_desde'):
|
||||||
|
filters['fecha_pago_real__gte'] = global_filters['fecha_pago_desde']
|
||||||
|
if global_filters.get('fecha_pago_hasta'):
|
||||||
|
filters['fecha_pago_real__lte'] = global_filters['fecha_pago_hasta']
|
||||||
|
|
||||||
|
return filters
|
||||||
|
|
||||||
|
|
||||||
|
def apply_related_filters(global_filters, model, related_keys):
|
||||||
|
"""Filtros para modo múltiple: globales + llaves de cruce entre modelos."""
|
||||||
|
filters = {}
|
||||||
|
model_fields = [f.name for f in model._meta.get_fields()]
|
||||||
|
|
||||||
|
if 'organizacion' in model_fields and global_filters.get('organizacion'):
|
||||||
|
org_value = global_filters['organizacion']
|
||||||
|
try:
|
||||||
|
field = model._meta.get_field('organizacion')
|
||||||
|
if hasattr(field, 'related_model'):
|
||||||
|
filters['organizacion_id'] = uuid.UUID(org_value)
|
||||||
|
else:
|
||||||
|
filters['organizacion'] = org_value
|
||||||
|
except Exception:
|
||||||
|
filters['organizacion_id'] = org_value
|
||||||
|
|
||||||
|
if 'rfc' in model_fields and global_filters.get('rfc'):
|
||||||
|
filters['rfc'] = global_filters['rfc']
|
||||||
|
|
||||||
|
if 'fecha_pago_real' in model_fields:
|
||||||
|
if global_filters.get('fecha_pago_desde'):
|
||||||
|
filters['fecha_pago_real__gte'] = global_filters['fecha_pago_desde']
|
||||||
|
if global_filters.get('fecha_pago_hasta'):
|
||||||
|
filters['fecha_pago_real__lte'] = global_filters['fecha_pago_hasta']
|
||||||
|
|
||||||
|
if any(related_keys.values()):
|
||||||
|
if related_keys.get('patentes') and 'patente' in model_fields:
|
||||||
|
filters['patente__in'] = related_keys['patentes']
|
||||||
|
if related_keys.get('pedimentos') and 'pedimento' in model_fields:
|
||||||
|
filters['pedimento__in'] = related_keys['pedimentos']
|
||||||
|
if related_keys.get('datastage_ids') and 'datastage_id' in model_fields:
|
||||||
|
filters['datastage_id__in'] = related_keys['datastage_ids']
|
||||||
|
else:
|
||||||
|
if 'patente' in model_fields and global_filters.get('patente'):
|
||||||
|
filters['patente'] = global_filters['patente']
|
||||||
|
if 'pedimento' in model_fields and global_filters.get('pedimento'):
|
||||||
|
filters['pedimento'] = global_filters['pedimento']
|
||||||
|
|
||||||
|
return filters
|
||||||
|
|
||||||
|
|
||||||
|
def get_related_keys_from_filters(global_filters, models_data):
|
||||||
|
"""
|
||||||
|
Construye el conjunto de (patente, pedimento, datastage_id) que servirá como
|
||||||
|
llave de cruce entre modelos.
|
||||||
|
|
||||||
|
Regla clave: si el filtro RFC está activo, solo los modelos que tienen el campo
|
||||||
|
'rfc' pueden contribuir a related_keys. Los modelos sin 'rfc' (ej. 505, 506)
|
||||||
|
no se usan como semilla — solo se filtrarán más tarde usando las claves ya
|
||||||
|
construidas, evitando que contaminen el resultado con pedimentos de otros RFC.
|
||||||
|
"""
|
||||||
|
related_keys = {
|
||||||
|
'patentes': set(),
|
||||||
|
'pedimentos': set(),
|
||||||
|
'datastage_ids': set()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sin filtros significativos → sin cruce
|
||||||
|
if not any(v for v in global_filters.values() if v not in [None, '']):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
rfc_filter_active = bool(global_filters.get('rfc'))
|
||||||
|
date_filter_active = bool(global_filters.get('fecha_pago_desde') or global_filters.get('fecha_pago_hasta'))
|
||||||
|
all_records_with_filters = []
|
||||||
|
|
||||||
|
for model_data in models_data:
|
||||||
|
model_name = model_data.get('model')
|
||||||
|
try:
|
||||||
|
model = apps.get_model('datastage', model_name)
|
||||||
|
model_field_names = {f.name for f in model._meta.get_fields() if hasattr(f, 'name')}
|
||||||
|
|
||||||
|
# Un modelo puede ser semilla de related_keys SOLO si tiene campos
|
||||||
|
# para aplicar TODOS los filtros activos
|
||||||
|
if rfc_filter_active and 'rfc' not in model_field_names:
|
||||||
|
continue
|
||||||
|
if date_filter_active and 'fecha_pago_real' not in model_field_names:
|
||||||
|
continue
|
||||||
|
|
||||||
|
filters = apply_global_filters_to_model(global_filters, model)
|
||||||
|
if not filters:
|
||||||
|
continue
|
||||||
|
|
||||||
|
records = model.objects.filter(**filters).values('patente', 'pedimento', 'datastage_id')
|
||||||
|
all_records_with_filters.extend(list(records))
|
||||||
|
|
||||||
|
except LookupError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not all_records_with_filters:
|
||||||
|
return {'patentes': set(), 'pedimentos': set(), 'datastage_ids': set()}
|
||||||
|
|
||||||
|
for record in all_records_with_filters:
|
||||||
|
if record.get('patente'):
|
||||||
|
related_keys['patentes'].add(record['patente'])
|
||||||
|
if record.get('pedimento'):
|
||||||
|
related_keys['pedimentos'].add(record['pedimento'])
|
||||||
|
if record.get('datastage_id'):
|
||||||
|
related_keys['datastage_ids'].add(record['datastage_id'])
|
||||||
|
|
||||||
|
return {k: list(v) for k, v in related_keys.items() if v}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Exportación simple (un solo modelo)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def build_simple_export(model_name, fields, global_filters, export_format, progress_cb=None):
|
||||||
|
progress_cb = progress_cb or (lambda p, m: None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
model = apps.get_model('datastage', model_name)
|
||||||
|
except LookupError:
|
||||||
|
raise ValueError(f'Modelo {model_name} no encontrado')
|
||||||
|
|
||||||
|
filters = apply_global_filters_to_model(global_filters, model)
|
||||||
|
queryset = model.objects.filter(**filters).values(*fields)
|
||||||
|
total_records = queryset.count()
|
||||||
|
progress_cb(20, f'{model_name}: {total_records} registros encontrados')
|
||||||
|
|
||||||
|
if export_format == 'excel':
|
||||||
|
if total_records > MAX_RECORDS_PER_FILE:
|
||||||
|
content, filename, content_type = _simple_excel_partitioned(model_name, fields, queryset, progress_cb)
|
||||||
|
else:
|
||||||
|
content, filename, content_type = _simple_excel(model_name, fields, queryset, progress_cb)
|
||||||
|
else:
|
||||||
|
# CSV no tiene límite de filas — siempre un solo archivo
|
||||||
|
content, filename, content_type = _simple_csv(model_name, fields, queryset, progress_cb)
|
||||||
|
|
||||||
|
return content, filename, content_type, total_records
|
||||||
|
|
||||||
|
|
||||||
|
def _simple_excel(model_name, fields, queryset, progress_cb):
|
||||||
|
progress_cb(40, f'Escribiendo Excel de {model_name}...')
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.append(fields)
|
||||||
|
for row in queryset:
|
||||||
|
ws.append([safe_excel_value(row[field]) for field in fields])
|
||||||
|
progress_cb(88, 'Serializando archivo...')
|
||||||
|
output = io.BytesIO()
|
||||||
|
wb.save(output)
|
||||||
|
return output.getvalue(), f'{model_name}.xlsx', XLSX_CONTENT_TYPE
|
||||||
|
|
||||||
|
|
||||||
|
def _simple_csv(model_name, fields, queryset, progress_cb):
|
||||||
|
progress_cb(40, f'Escribiendo CSV de {model_name}...')
|
||||||
|
buf = io.StringIO()
|
||||||
|
writer = csv.DictWriter(buf, fieldnames=fields)
|
||||||
|
writer.writeheader()
|
||||||
|
for row in queryset:
|
||||||
|
writer.writerow(row)
|
||||||
|
progress_cb(88, 'Serializando archivo...')
|
||||||
|
return buf.getvalue().encode('utf-8'), f'{model_name}.csv', CSV_CONTENT_TYPE
|
||||||
|
|
||||||
|
|
||||||
|
def _simple_excel_partitioned(model_name, fields, queryset, progress_cb):
|
||||||
|
zip_buffer = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
|
||||||
|
paginator = Paginator(queryset, MAX_RECORDS_PER_FILE)
|
||||||
|
for page_num in paginator.page_range:
|
||||||
|
pct = 25 + int((page_num / paginator.num_pages) * 55)
|
||||||
|
progress_cb(pct, f'Particionando {model_name}: parte {page_num}/{paginator.num_pages}')
|
||||||
|
page = paginator.page(page_num)
|
||||||
|
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = f'Parte_{page_num}'[:31]
|
||||||
|
ws.append(fields)
|
||||||
|
for row in page.object_list:
|
||||||
|
ws.append([safe_excel_value(row[field]) for field in fields])
|
||||||
|
|
||||||
|
part_buffer = io.BytesIO()
|
||||||
|
wb.save(part_buffer)
|
||||||
|
zip_file.writestr(f'{model_name}_part{page_num}.xlsx', part_buffer.getvalue())
|
||||||
|
|
||||||
|
progress_cb(88, 'Serializando archivo...')
|
||||||
|
return zip_buffer.getvalue(), f'{model_name}_particionado.zip', ZIP_CONTENT_TYPE
|
||||||
|
|
||||||
|
|
||||||
|
def _simple_csv_partitioned(model_name, fields, queryset, progress_cb):
|
||||||
|
zip_buffer = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
|
||||||
|
paginator = Paginator(queryset, MAX_RECORDS_PER_FILE)
|
||||||
|
for page_num in paginator.page_range:
|
||||||
|
pct = 25 + int((page_num / paginator.num_pages) * 55)
|
||||||
|
progress_cb(pct, f'Particionando {model_name}: parte {page_num}/{paginator.num_pages}')
|
||||||
|
page = paginator.page(page_num)
|
||||||
|
|
||||||
|
csv_buffer = io.StringIO()
|
||||||
|
writer = csv.writer(csv_buffer)
|
||||||
|
writer.writerow(fields)
|
||||||
|
for row in page.object_list:
|
||||||
|
writer.writerow([safe_excel_value(row[field]) for field in fields])
|
||||||
|
|
||||||
|
zip_file.writestr(f'{model_name}_part{page_num}.csv', csv_buffer.getvalue())
|
||||||
|
|
||||||
|
progress_cb(88, 'Serializando archivo...')
|
||||||
|
return zip_buffer.getvalue(), f'{model_name}_particionado.zip', ZIP_CONTENT_TYPE
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Exportación múltiple (varios modelos agrupados por llaves de cruce)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _collect_multiple_data(models_data, global_filters, related_keys, progress_cb):
|
||||||
|
"""
|
||||||
|
Recolecta y agrupa los registros de todos los modelos por la llave
|
||||||
|
seccion_aduanera + patente + pedimento. Mapea organizacion_id → nombre.
|
||||||
|
"""
|
||||||
|
org_mapping = {str(org.id): org.nombre for org in Organizacion.objects.all()}
|
||||||
|
all_models_data = {}
|
||||||
|
total_models = len(models_data) or 1
|
||||||
|
|
||||||
|
for idx, model_data in enumerate(models_data):
|
||||||
|
model_name = model_data.get('model')
|
||||||
|
fields = model_data.get('fields', [])
|
||||||
|
|
||||||
|
if not model_name or not fields:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Normalizar campos: 'organizacion' → 'organizacion_id', sin duplicados
|
||||||
|
normalized_fields = []
|
||||||
|
for f in fields:
|
||||||
|
key = f.strip() if isinstance(f, str) else f
|
||||||
|
if isinstance(key, str) and key.lower() == 'organizacion':
|
||||||
|
if 'organizacion_id' not in normalized_fields:
|
||||||
|
normalized_fields.append('organizacion_id')
|
||||||
|
else:
|
||||||
|
if key not in normalized_fields:
|
||||||
|
normalized_fields.append(key)
|
||||||
|
fields = normalized_fields
|
||||||
|
|
||||||
|
for req_field in RELATION_FIELDS:
|
||||||
|
if req_field not in fields:
|
||||||
|
fields.append(req_field)
|
||||||
|
|
||||||
|
try:
|
||||||
|
model = apps.get_model('datastage', model_name)
|
||||||
|
model_field_names = [f.name for f in model._meta.get_fields() if hasattr(f, 'name')]
|
||||||
|
if 'organizacion_id' not in fields and 'organizacion_id' in model_field_names:
|
||||||
|
fields.append('organizacion_id')
|
||||||
|
|
||||||
|
filters = apply_related_filters(global_filters, model, related_keys)
|
||||||
|
queryset = model.objects.filter(**filters).values(*fields) if filters else model.objects.none()
|
||||||
|
|
||||||
|
count = queryset.count()
|
||||||
|
pct = 20 + int((idx / total_models) * 55)
|
||||||
|
progress_cb(pct, f'Modelo {idx + 1}/{total_models}: {model_name} ({count} registros)')
|
||||||
|
if count == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
relation_fields = [fn for fn in RELATION_FIELDS if fn in fields]
|
||||||
|
if not relation_fields:
|
||||||
|
relation_fields = ['datastage_id'] if 'datastage_id' in fields else [fields[0]]
|
||||||
|
|
||||||
|
for record in queryset:
|
||||||
|
key_parts = [str(record[rf]) for rf in relation_fields if rf in record and record[rf] is not None]
|
||||||
|
if not key_parts:
|
||||||
|
key = hashlib.md5(str(sorted(record.items())).encode()).hexdigest()[:10]
|
||||||
|
else:
|
||||||
|
key = "_".join(key_parts)
|
||||||
|
|
||||||
|
processed_record = {}
|
||||||
|
for field_name, value in record.items():
|
||||||
|
if field_name == 'organizacion_id' and value:
|
||||||
|
org_id_str = str(value)
|
||||||
|
if org_id_str in org_mapping:
|
||||||
|
processed_value = org_mapping[org_id_str]
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
org = Organizacion.objects.filter(id=value).first()
|
||||||
|
processed_value = org.nombre if org else org_id_str
|
||||||
|
org_mapping[org_id_str] = processed_value
|
||||||
|
except Exception:
|
||||||
|
processed_value = org_id_str
|
||||||
|
else:
|
||||||
|
processed_value = value
|
||||||
|
|
||||||
|
if field_name in relation_fields:
|
||||||
|
prefixed_field_name = field_name
|
||||||
|
else:
|
||||||
|
prefixed_field_name = f"{model_name}_{field_name}"
|
||||||
|
|
||||||
|
if field_name == 'organizacion_id':
|
||||||
|
prefixed_field_name = prefixed_field_name.replace('organizacion_id', 'organizacion_nombre')
|
||||||
|
|
||||||
|
processed_record[prefixed_field_name] = safe_excel_value(processed_value)
|
||||||
|
|
||||||
|
if key not in all_models_data:
|
||||||
|
all_models_data[key] = {'relation_fields': {}, 'model_records': {}}
|
||||||
|
|
||||||
|
for rel_field in relation_fields:
|
||||||
|
if rel_field in record:
|
||||||
|
all_models_data[key]['relation_fields'][rel_field] = record[rel_field]
|
||||||
|
|
||||||
|
if model_name not in all_models_data[key]['model_records']:
|
||||||
|
all_models_data[key]['model_records'][model_name] = []
|
||||||
|
|
||||||
|
all_models_data[key]['model_records'][model_name].append(processed_record)
|
||||||
|
|
||||||
|
except LookupError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return all_models_data
|
||||||
|
|
||||||
|
|
||||||
|
def _build_combined_rows(all_models_data):
|
||||||
|
"""Construye filas combinadas — repite el último registro en lugar de dejar vacíos."""
|
||||||
|
combined_rows = []
|
||||||
|
for key, data in all_models_data.items():
|
||||||
|
relation_fields_data = data['relation_fields']
|
||||||
|
model_records = data['model_records']
|
||||||
|
|
||||||
|
max_records_per_key = max((len(recs) for recs in model_records.values()), default=1)
|
||||||
|
|
||||||
|
for i in range(max_records_per_key):
|
||||||
|
row_data = {}
|
||||||
|
for rel_field, rel_value in relation_fields_data.items():
|
||||||
|
row_data[rel_field] = safe_excel_value(rel_value)
|
||||||
|
for model_name, records in model_records.items():
|
||||||
|
# Usar posición i o el último registro disponible
|
||||||
|
record = records[i] if i < len(records) else records[-1]
|
||||||
|
for field_name, value in record.items():
|
||||||
|
row_data[field_name] = value
|
||||||
|
combined_rows.append(row_data)
|
||||||
|
|
||||||
|
return combined_rows
|
||||||
|
|
||||||
|
|
||||||
|
def _ordered_fields(combined_rows):
|
||||||
|
"""Encabezados: campos de relación primero, luego organización, luego el resto."""
|
||||||
|
all_fields_set = set()
|
||||||
|
for row in combined_rows:
|
||||||
|
all_fields_set.update(row.keys())
|
||||||
|
|
||||||
|
all_fields = []
|
||||||
|
for rel_field in RELATION_FIELDS:
|
||||||
|
if rel_field in all_fields_set:
|
||||||
|
all_fields.append(rel_field)
|
||||||
|
all_fields_set.discard(rel_field)
|
||||||
|
|
||||||
|
org_fields = sorted(f for f in all_fields_set if 'organizacion' in f.lower())
|
||||||
|
for org_field in org_fields:
|
||||||
|
all_fields.append(org_field)
|
||||||
|
all_fields_set.discard(org_field)
|
||||||
|
|
||||||
|
all_fields.extend(sorted(all_fields_set))
|
||||||
|
return all_fields
|
||||||
|
|
||||||
|
|
||||||
|
def build_multiple_export(models_data, global_filters, export_format, progress_cb=None):
|
||||||
|
progress_cb = progress_cb or (lambda p, m: None)
|
||||||
|
|
||||||
|
progress_cb(15, 'Resolviendo llaves de cruce entre modelos...')
|
||||||
|
related_keys = get_related_keys_from_filters(global_filters, models_data)
|
||||||
|
|
||||||
|
all_models_data = _collect_multiple_data(models_data, global_filters, related_keys, progress_cb)
|
||||||
|
|
||||||
|
# Sin datos → archivo con mensaje, no error (el frontend espera un archivo)
|
||||||
|
if not all_models_data:
|
||||||
|
if export_format == 'excel':
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "Sin datos"
|
||||||
|
ws.append(["No se encontraron datos para los filtros especificados"])
|
||||||
|
output = io.BytesIO()
|
||||||
|
wb.save(output)
|
||||||
|
return output.getvalue(), 'datastage_sin_datos.xlsx', XLSX_CONTENT_TYPE, 0
|
||||||
|
else:
|
||||||
|
buf = io.StringIO()
|
||||||
|
csv.writer(buf).writerow(['No se encontraron datos para los filtros especificados'])
|
||||||
|
return buf.getvalue().encode('utf-8'), 'datastage_sin_datos.csv', CSV_CONTENT_TYPE, 0
|
||||||
|
|
||||||
|
progress_cb(80, 'Combinando filas...')
|
||||||
|
combined_rows = _build_combined_rows(all_models_data)
|
||||||
|
all_fields = _ordered_fields(combined_rows)
|
||||||
|
total_rows = len(combined_rows)
|
||||||
|
|
||||||
|
if export_format == 'excel':
|
||||||
|
content, filename, content_type = _multiple_excel(combined_rows, all_fields, progress_cb)
|
||||||
|
else:
|
||||||
|
content, filename, content_type = _multiple_csv(combined_rows, all_fields, progress_cb)
|
||||||
|
|
||||||
|
return content, filename, content_type, total_rows
|
||||||
|
|
||||||
|
|
||||||
|
def _multiple_excel(combined_rows, all_fields, progress_cb):
|
||||||
|
now_str = datetime.datetime.now().strftime('%d/%m/%Y %H:%M:%S')
|
||||||
|
title_row = ["Reporte Datastage"]
|
||||||
|
date_row = [f"Generado: {now_str}"]
|
||||||
|
|
||||||
|
def _write_sheet(ws, sheet_name, page_rows):
|
||||||
|
ws.title = sheet_name[:31]
|
||||||
|
ws.append(title_row)
|
||||||
|
ws.append(date_row)
|
||||||
|
ws.append([])
|
||||||
|
ws.append(all_fields)
|
||||||
|
for row_data in page_rows:
|
||||||
|
ws.append([row_data.get(field, '') for field in all_fields])
|
||||||
|
for column in ws.columns:
|
||||||
|
max_length = 0
|
||||||
|
col_letter = column[0].column_letter
|
||||||
|
for cell in column:
|
||||||
|
try:
|
||||||
|
if len(str(cell.value)) > max_length:
|
||||||
|
max_length = len(str(cell.value))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
ws.column_dimensions[col_letter].width = min(max_length + 2, 50)
|
||||||
|
|
||||||
|
# Excel directo si cabe en un archivo; ZIP solo si se necesita particionar
|
||||||
|
paginator = Paginator(combined_rows, MAX_RECORDS_PER_FILE)
|
||||||
|
|
||||||
|
if paginator.num_pages == 1:
|
||||||
|
progress_cb(88, 'Serializando archivo...')
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
_write_sheet(wb.active, "Datastage", paginator.page(1).object_list)
|
||||||
|
output = io.BytesIO()
|
||||||
|
wb.save(output)
|
||||||
|
return output.getvalue(), 'datastage_reporte.xlsx', XLSX_CONTENT_TYPE
|
||||||
|
|
||||||
|
zip_buffer = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
|
||||||
|
for page_num in paginator.page_range:
|
||||||
|
progress_cb(80 + int((page_num / paginator.num_pages) * 8),
|
||||||
|
f'Particionando: parte {page_num}/{paginator.num_pages}')
|
||||||
|
page = paginator.page(page_num)
|
||||||
|
current_wb = openpyxl.Workbook()
|
||||||
|
_write_sheet(current_wb.active, f"Datastage_p{page_num}", page.object_list)
|
||||||
|
part_buffer = io.BytesIO()
|
||||||
|
current_wb.save(part_buffer)
|
||||||
|
zip_file.writestr(f"datastage_part{page_num}.xlsx", part_buffer.getvalue())
|
||||||
|
|
||||||
|
progress_cb(88, 'Serializando archivo...')
|
||||||
|
return zip_buffer.getvalue(), 'datastage_combinado.zip', ZIP_CONTENT_TYPE
|
||||||
|
|
||||||
|
|
||||||
|
def _multiple_csv(combined_rows, all_fields, progress_cb):
|
||||||
|
progress_cb(88, 'Serializando archivo...')
|
||||||
|
buf = io.StringIO()
|
||||||
|
writer = csv.writer(buf)
|
||||||
|
writer.writerow(all_fields)
|
||||||
|
for row_data in combined_rows:
|
||||||
|
writer.writerow([row_data.get(field, '') for field in all_fields])
|
||||||
|
return buf.getvalue().encode('utf-8'), 'datastage_reporte.csv', CSV_CONTENT_TYPE
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dispatcher
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def build_datastage_export(payload, progress_cb=None):
|
||||||
|
"""
|
||||||
|
Genera el reporte DataStage a partir del payload persistido en
|
||||||
|
ReportDocument.filters. Lanza ValueError si el payload es inválido.
|
||||||
|
|
||||||
|
Retorna (content_bytes, filename, content_type, total_rows).
|
||||||
|
"""
|
||||||
|
modo = payload.get('modo', 'simple')
|
||||||
|
export_format = payload.get('format', 'csv')
|
||||||
|
global_filters = payload.get('globalFilters') or {}
|
||||||
|
|
||||||
|
if modo == 'multiple':
|
||||||
|
models_data = payload.get('models') or []
|
||||||
|
if not models_data:
|
||||||
|
raise ValueError('models es requerido para exportación múltiple')
|
||||||
|
return build_multiple_export(models_data, global_filters, export_format, progress_cb)
|
||||||
|
|
||||||
|
model_name = payload.get('model')
|
||||||
|
fields = payload.get('fields')
|
||||||
|
if not model_name or not fields:
|
||||||
|
raise ValueError('model y fields son requeridos para exportación simple')
|
||||||
|
return build_simple_export(model_name, fields, global_filters, export_format, progress_cb)
|
||||||
3
api/reports/tasks/__init__.py
Normal file
3
api/reports/tasks/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Importa los módulos de tasks para que autodiscover_tasks() los registre en el worker
|
||||||
|
from .report_document import generate_report_document, generate_report_control_pedimento
|
||||||
|
from .report_datastage import generate_report_datastage
|
||||||
105
api/reports/tasks/report_datastage.py
Normal file
105
api/reports/tasks/report_datastage.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import logging
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
from celery import shared_task
|
||||||
|
from celery.exceptions import SoftTimeLimitExceeded
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from api.reports.models import ReportDocument
|
||||||
|
from api.reports.services.datastage_export import build_datastage_export
|
||||||
|
from api.utils.storage_service import storage_service
|
||||||
|
from core.redis_events import publish_task_event
|
||||||
|
|
||||||
|
logger = logging.getLogger('api.reports.tasks')
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True, queue='reports', soft_time_limit=1800, time_limit=1860)
|
||||||
|
def generate_report_datastage(self, report_id):
|
||||||
|
task_id = self.request.id
|
||||||
|
report = None
|
||||||
|
|
||||||
|
def _fail(msg, exc=None):
|
||||||
|
"""Marca el reporte como error, notifica al frontend y loguea. Sin re-raise."""
|
||||||
|
tb = traceback.format_exc() if exc else ''
|
||||||
|
full_msg = f"{msg}\n\n{tb}".strip() if tb else msg
|
||||||
|
logger.error('[reporte_datastage] report=%s FALLO: %s', report_id, full_msg)
|
||||||
|
if report:
|
||||||
|
report.status = 'error'
|
||||||
|
report.error_message = full_msg
|
||||||
|
report.finished_at = timezone.now()
|
||||||
|
report.save(update_fields=['status', 'error_message', 'finished_at'])
|
||||||
|
publish_task_event(task_id, 'failed', msg, progress=0)
|
||||||
|
|
||||||
|
# ── 1. Obtener reporte ────────────────────────────────────────────────────
|
||||||
|
try:
|
||||||
|
report = ReportDocument.objects.get(id=report_id)
|
||||||
|
except ReportDocument.DoesNotExist:
|
||||||
|
logger.error('[reporte_datastage] ReportDocument %s no existe', report_id)
|
||||||
|
publish_task_event(task_id, 'failed', f'Reporte {report_id} no encontrado', progress=0)
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info('[reporte_datastage] Iniciando report=%s user=%s', report_id, report.user_id)
|
||||||
|
report.status = 'processing'
|
||||||
|
report.save(update_fields=['status'])
|
||||||
|
publish_task_event(task_id, 'processing', 'Iniciando generación de reporte...', progress=5)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# La organización ya viene resuelta en el payload (la vista la fija antes de encolar)
|
||||||
|
payload = report.filters or {}
|
||||||
|
org_id = payload.get('organizacion_id')
|
||||||
|
|
||||||
|
def _progress(pct, msg):
|
||||||
|
publish_task_event(task_id, 'processing', msg, progress=pct)
|
||||||
|
|
||||||
|
# ── 2. Generar archivo (xlsx / csv / zip según modo, formato y volumen) ──
|
||||||
|
content, filename, content_type, total_rows = build_datastage_export(payload, _progress)
|
||||||
|
|
||||||
|
# ── 3. Subir a almacenamiento ─────────────────────────────────────────
|
||||||
|
logger.info('[reporte_datastage] report=%s archivo=%s size=%.1fKB filas=%d',
|
||||||
|
report_id, filename, len(content) / 1024, total_rows)
|
||||||
|
publish_task_event(task_id, 'processing', 'Subiendo a almacenamiento...', progress=93)
|
||||||
|
|
||||||
|
final_name = f"datastage_{report.id}_{timezone.now().strftime('%Y%m%d%H%M%S')}_{filename}"
|
||||||
|
ruta = storage_service.save_report(
|
||||||
|
file=SimpleUploadedFile(
|
||||||
|
name=final_name,
|
||||||
|
content=content,
|
||||||
|
content_type=content_type,
|
||||||
|
),
|
||||||
|
organizacion_id=org_id,
|
||||||
|
metadata={
|
||||||
|
'report_id': str(report.id),
|
||||||
|
'report_type': 'datastage',
|
||||||
|
'user_id': str(report.user.id) if report.user else None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if ruta:
|
||||||
|
logger.info('[reporte_datastage] report=%s guardado en storage=%s', report_id, ruta)
|
||||||
|
report.file = ruta
|
||||||
|
report.status = 'ready'
|
||||||
|
else:
|
||||||
|
_fail('Error al guardar el archivo en almacenamiento (storage retornó None)')
|
||||||
|
return
|
||||||
|
|
||||||
|
report.finished_at = timezone.now()
|
||||||
|
report.save(update_fields=['status', 'file', 'finished_at', 'error_message'])
|
||||||
|
|
||||||
|
resultado = {
|
||||||
|
'report_id': str(report.id),
|
||||||
|
'total_registros': total_rows,
|
||||||
|
'archivo': final_name,
|
||||||
|
}
|
||||||
|
publish_task_event(task_id, 'completed', 'Reporte generado exitosamente.', progress=100, resultado=resultado)
|
||||||
|
logger.info('[reporte_datastage] report=%s COMPLETADO filas=%d', report_id, total_rows)
|
||||||
|
return resultado
|
||||||
|
|
||||||
|
except SoftTimeLimitExceeded:
|
||||||
|
_fail('El reporte tardó más de 30 minutos y fue cancelado. Intenta con filtros más acotados.')
|
||||||
|
|
||||||
|
except ValueError as exc:
|
||||||
|
_fail(str(exc))
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
_fail(str(exc), exc=exc)
|
||||||
@@ -1,102 +1,383 @@
|
|||||||
from celery import shared_task
|
import io
|
||||||
from api.organization.models import Organizacion
|
import logging
|
||||||
from django.core.files.base import ContentFile
|
|
||||||
from django.utils import timezone
|
|
||||||
from api.reports.models import ReportDocument
|
|
||||||
from api.customs.models import Pedimento, Cove, EDocument, Partida
|
|
||||||
from django.db.models import Q, Exists, OuterRef
|
|
||||||
# from django.db.models import Q,
|
|
||||||
from api.record.models import Document
|
|
||||||
import csv
|
|
||||||
import os
|
import os
|
||||||
from django.conf import settings
|
import tempfile
|
||||||
|
import traceback
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
@shared_task
|
import openpyxl
|
||||||
def generate_report_document(report_id):
|
from openpyxl.styles import Alignment, Font, PatternFill
|
||||||
|
|
||||||
|
from celery import shared_task
|
||||||
|
from celery.exceptions import SoftTimeLimitExceeded
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from api.customs.models import Cove, EDocument, Partida, Pedimento
|
||||||
|
from api.organization.models import Organizacion
|
||||||
|
from api.record.models import Document
|
||||||
|
from api.reports.models import ReportDocument
|
||||||
|
from api.utils.storage_service import storage_service
|
||||||
|
from core.redis_events import publish_task_event
|
||||||
|
|
||||||
|
logger = logging.getLogger('api.reports.tasks')
|
||||||
|
|
||||||
|
|
||||||
|
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _estado(flag: bool) -> str:
|
||||||
|
return 'RECUPERADO' if flag else 'PENDIENTE'
|
||||||
|
|
||||||
|
|
||||||
|
def _build_pedimento_filters(filters: dict) -> Q:
|
||||||
|
q = Q()
|
||||||
|
if filters.get('organizacion_id'):
|
||||||
|
q &= Q(organizacion_id=filters['organizacion_id'])
|
||||||
|
if filters.get('fecha_pago__gte'):
|
||||||
|
q &= Q(fecha_pago__gte=filters['fecha_pago__gte'])
|
||||||
|
if filters.get('fecha_pago__lte'):
|
||||||
|
q &= Q(fecha_pago__lte=filters['fecha_pago__lte'])
|
||||||
|
if filters.get('patente'):
|
||||||
|
q &= Q(patente=filters['patente'])
|
||||||
|
if filters.get('aduana'):
|
||||||
|
q &= Q(aduana=filters['aduana'])
|
||||||
|
if filters.get('pedimento'):
|
||||||
|
q &= Q(pedimento=filters['pedimento'])
|
||||||
|
if filters.get('pedimento_app'):
|
||||||
|
q &= Q(pedimento_app=filters['pedimento_app'])
|
||||||
|
if filters.get('regimen'):
|
||||||
|
q &= Q(regimen=filters['regimen'])
|
||||||
|
if filters.get('tipo_operacion'):
|
||||||
|
q &= Q(tipo_operacion_id=filters['tipo_operacion'])
|
||||||
|
rfc_val = filters.get('contribuyente__rfc')
|
||||||
|
if rfc_val:
|
||||||
|
if rfc_val == 'SIN_RFC':
|
||||||
|
q &= Q(contribuyente__isnull=True)
|
||||||
|
else:
|
||||||
|
q &= Q(contribuyente__rfc=rfc_val)
|
||||||
|
return q
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_user_rfc_filter(q: Q, user, requested_rfc: str | None) -> Q:
|
||||||
|
"""Restringe el queryset a los importadores visibles del usuario."""
|
||||||
|
# SIN_RFC ya fue aplicado en _build_pedimento_filters como contribuyente__isnull=True
|
||||||
|
if requested_rfc == 'SIN_RFC':
|
||||||
|
return q
|
||||||
|
user_rfcs = user.rfc.all()
|
||||||
|
if not user_rfcs.exists():
|
||||||
|
if requested_rfc:
|
||||||
|
q &= Q(contribuyente__rfc=requested_rfc)
|
||||||
|
return q
|
||||||
|
if requested_rfc:
|
||||||
|
if user_rfcs.filter(rfc=requested_rfc).exists():
|
||||||
|
q &= Q(contribuyente__rfc=requested_rfc)
|
||||||
|
else:
|
||||||
|
q &= Q(contribuyente__in=user_rfcs)
|
||||||
|
else:
|
||||||
|
q &= Q(contribuyente__in=user_rfcs)
|
||||||
|
return q
|
||||||
|
|
||||||
|
|
||||||
|
# ── tarea principal ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@shared_task(bind=True, queue='reports', soft_time_limit=600, time_limit=660)
|
||||||
|
def generate_report_document(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_cumplimiento] 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:
|
try:
|
||||||
report = ReportDocument.objects.get(id=report_id)
|
report = ReportDocument.objects.get(id=report_id)
|
||||||
report.status = 'processing'
|
except ReportDocument.DoesNotExist:
|
||||||
report.save(update_fields=['status'])
|
logger.error('[reporte_cumplimiento] ReportDocument %s no existe', report_id)
|
||||||
|
publish_task_event(task_id, 'failed', f'Reporte {report_id} no encontrado', progress=0)
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info('[reporte_cumplimiento] 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:
|
||||||
filters = report.filters or {}
|
filters = report.filters or {}
|
||||||
pedimentos_filters = Q()
|
org_id = filters.get('organizacion_id')
|
||||||
if filters.get('organizacion_id'):
|
|
||||||
pedimentos_filters &= Q(organizacion_id=filters['organizacion_id'])
|
# ── 2. Filtros y organización ─────────────────────────────────────────
|
||||||
if filters.get('fecha_pago__gte'):
|
q = _build_pedimento_filters(filters)
|
||||||
pedimentos_filters &= Q(fecha_pago__gte=filters['fecha_pago__gte'])
|
q = _apply_user_rfc_filter(q, report.user, filters.get('contribuyente__rfc'))
|
||||||
if filters.get('fecha_pago__lte'):
|
|
||||||
pedimentos_filters &= Q(fecha_pago__lte=filters['fecha_pago__lte'])
|
nombre_org = ''
|
||||||
if filters.get('contribuyente__rfc'):
|
if org_id:
|
||||||
pedimentos_filters &= Q(contribuyente__rfc=filters['contribuyente__rfc'])
|
try:
|
||||||
if filters.get('patente'):
|
nombre_org = Organizacion.objects.get(id=org_id).nombre
|
||||||
pedimentos_filters &= Q(patente=filters['patente'])
|
except Organizacion.DoesNotExist:
|
||||||
if filters.get('aduana'):
|
pass
|
||||||
pedimentos_filters &= Q(aduana=filters['aduana'])
|
|
||||||
if filters.get('pedimento'):
|
logger.info('[reporte_cumplimiento] report=%s org=%s filtros=%s', report_id, nombre_org, filters)
|
||||||
pedimentos_filters &= Q(pedimento=filters['pedimento'])
|
publish_task_event(task_id, 'processing', f'Consultando RFCs de {nombre_org}...', progress=10)
|
||||||
if filters.get('pedimento_app'):
|
|
||||||
pedimentos_filters &= Q(pedimento_app=filters['pedimento_app'])
|
# ── 3. Listar RFCs (consulta liviana) ────────────────────────────────
|
||||||
if filters.get('regimen'):
|
rfcs_list = list(
|
||||||
pedimentos_filters &= Q(regimen=filters['regimen'])
|
Pedimento.objects.filter(q)
|
||||||
if filters.get('tipo_operacion'):
|
.exclude(contribuyente__isnull=True)
|
||||||
pedimentos_filters &= Q(tipo_operacion_id=filters['tipo_operacion'])
|
.values_list('contribuyente__rfc', flat=True)
|
||||||
# Consulta asíncrona de los modelos
|
.distinct()
|
||||||
pedimentos = Pedimento.objects.filter(pedimentos_filters)
|
.order_by('contribuyente__rfc')
|
||||||
filename = filters.get('filename')
|
)
|
||||||
if filename:
|
if Pedimento.objects.filter(q, contribuyente__isnull=True).exists():
|
||||||
filename = f"{filename}.csv" if not filename.endswith('.csv') else filename
|
rfcs_list.append('SIN_RFC')
|
||||||
|
|
||||||
|
total_rfcs = len(rfcs_list)
|
||||||
|
total_pedimentos = Pedimento.objects.filter(q).count()
|
||||||
|
|
||||||
|
logger.info('[reporte_cumplimiento] report=%s total_rfcs=%d total_pedimentos=%d',
|
||||||
|
report_id, total_rfcs, total_pedimentos)
|
||||||
|
|
||||||
|
if total_rfcs == 0:
|
||||||
|
logger.warning('[reporte_cumplimiento] report=%s sin pedimentos para los filtros dados', report_id)
|
||||||
|
|
||||||
|
publish_task_event(
|
||||||
|
task_id, 'processing',
|
||||||
|
f'{total_rfcs} RFC(s) — {total_pedimentos} pedimentos', progress=15,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 4. Crear workbook ─────────────────────────────────────────────────
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = 'Reporte Cumplimiento'
|
||||||
|
|
||||||
|
title_fill = PatternFill(start_color='1F4E79', end_color='1F4E79', fill_type='solid')
|
||||||
|
title_font = Font(color='FFFFFF', bold=True, size=12)
|
||||||
|
sub_fill = PatternFill(start_color='2E75B6', end_color='2E75B6', fill_type='solid')
|
||||||
|
sub_font = Font(color='FFFFFF', bold=True, size=10)
|
||||||
|
col_h_fill = PatternFill(start_color='D6E4F0', end_color='D6E4F0', fill_type='solid')
|
||||||
|
col_h_font = Font(bold=True, size=10)
|
||||||
|
footer_fill = PatternFill(start_color='E2EFDA', end_color='E2EFDA', fill_type='solid')
|
||||||
|
center = Alignment(horizontal='center', vertical='center', wrap_text=True)
|
||||||
|
top_left = Alignment(horizontal='left', vertical='top', wrap_text=True)
|
||||||
|
|
||||||
|
COL_HEADERS = [
|
||||||
|
'Año', 'Aduana', 'Patente', 'Pedimento',
|
||||||
|
'Nomenclatura Completo Pedimento', 'Clav', 'Tipo Operación',
|
||||||
|
'Expediente Sí', 'Documento', 'Estatus',
|
||||||
|
]
|
||||||
|
TOTAL_COLS = len(COL_HEADERS)
|
||||||
|
current_row = 1
|
||||||
|
safe_total = max(total_rfcs, 1)
|
||||||
|
|
||||||
|
# ── 5. Procesar RFC por RFC ───────────────────────────────────────────
|
||||||
|
for rfc_idx, rfc in enumerate(rfcs_list):
|
||||||
|
pct = 20 + int((rfc_idx / safe_total) * 65)
|
||||||
|
publish_task_event(
|
||||||
|
task_id, 'processing',
|
||||||
|
f'RFC {rfc_idx + 1}/{total_rfcs}: {rfc}', progress=pct,
|
||||||
|
)
|
||||||
|
|
||||||
|
rfc_q = (
|
||||||
|
q & Q(contribuyente__isnull=True) if rfc == 'SIN_RFC'
|
||||||
|
else q & Q(contribuyente__rfc=rfc)
|
||||||
|
)
|
||||||
|
|
||||||
|
peds = list(
|
||||||
|
Pedimento.objects.filter(rfc_q)
|
||||||
|
.select_related('contribuyente', 'tipo_operacion')
|
||||||
|
.order_by('fecha_pago')
|
||||||
|
)
|
||||||
|
if not peds:
|
||||||
|
logger.warning('[reporte_cumplimiento] report=%s rfc=%s sin pedimentos, omitido', report_id, rfc)
|
||||||
|
continue
|
||||||
|
|
||||||
|
ped_ids = [p.id for p in peds]
|
||||||
|
razon_social = nombre_org or 'Desconocido'
|
||||||
|
|
||||||
|
logger.info('[reporte_cumplimiento] report=%s rfc=%s pedimentos=%d',
|
||||||
|
report_id, rfc, len(peds))
|
||||||
|
|
||||||
|
# documentos de este RFC solamente
|
||||||
|
coves_map: dict = defaultdict(list)
|
||||||
|
for c in Cove.objects.filter(pedimento_id__in=ped_ids):
|
||||||
|
coves_map[c.pedimento_id].append(c)
|
||||||
|
|
||||||
|
edocs_map: dict = defaultdict(list)
|
||||||
|
for e in EDocument.objects.filter(pedimento_id__in=ped_ids):
|
||||||
|
edocs_map[e.pedimento_id].append(e)
|
||||||
|
|
||||||
|
partidas_map: dict = defaultdict(list)
|
||||||
|
for p in Partida.objects.filter(pedimento_id__in=ped_ids).order_by('numero_partida'):
|
||||||
|
partidas_map[p.pedimento_id].append(p)
|
||||||
|
|
||||||
|
remesa_ped_ids: set = set(
|
||||||
|
Document.objects.filter(pedimento_id__in=ped_ids, document_type_id=15)
|
||||||
|
.values_list('pedimento_id', flat=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
total_coves = sum(len(v) for v in coves_map.values())
|
||||||
|
total_edocs = sum(len(v) for v in edocs_map.values())
|
||||||
|
total_partidas = sum(len(v) for v in partidas_map.values())
|
||||||
|
est_rows = len(peds) + total_partidas + total_coves * 2 + total_edocs * 2 + len(remesa_ped_ids)
|
||||||
|
logger.info('[reporte_cumplimiento] report=%s rfc=%s docs coves=%d edocs=%d partidas=%d remesas=%d filas_estimadas=%d',
|
||||||
|
report_id, rfc, total_coves, total_edocs, total_partidas, len(remesa_ped_ids), est_rows)
|
||||||
|
|
||||||
|
# encabezado sección
|
||||||
|
ws.merge_cells(start_row=current_row, start_column=1, end_row=current_row, end_column=TOTAL_COLS)
|
||||||
|
cell = ws.cell(row=current_row, column=1, value='Reporte Integración de Expedientes.')
|
||||||
|
cell.fill, cell.font, cell.alignment = title_fill, title_font, center
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
ws.merge_cells(start_row=current_row, start_column=1, end_row=current_row, end_column=TOTAL_COLS)
|
||||||
|
cell = ws.cell(row=current_row, column=1, value=f'Razón Social Importador: {razon_social}')
|
||||||
|
cell.fill, cell.font = sub_fill, sub_font
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
ws.merge_cells(start_row=current_row, start_column=1, end_row=current_row, end_column=TOTAL_COLS)
|
||||||
|
cell = ws.cell(row=current_row, column=1, value=f'RFC: {rfc}')
|
||||||
|
cell.fill, cell.font = sub_fill, sub_font
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
for col_i, header in enumerate(COL_HEADERS, 1):
|
||||||
|
cell = ws.cell(row=current_row, column=col_i, value=header)
|
||||||
|
cell.fill, cell.font, cell.alignment = col_h_fill, col_h_font, center
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
total_exp = len(peds)
|
||||||
|
exp_con_docs = exp_completos = 0
|
||||||
|
|
||||||
|
for ped in peds:
|
||||||
|
doc_rows = [('PEDIMENTO COMPLETO', _estado(ped.existe_expediente))]
|
||||||
|
|
||||||
|
for partida in partidas_map[ped.id]:
|
||||||
|
doc_rows.append((f'PARTIDA{partida.numero_partida}', _estado(partida.descargado)))
|
||||||
|
if ped.remesas:
|
||||||
|
doc_rows.append(('REMESA', _estado(ped.id in remesa_ped_ids)))
|
||||||
|
for cove in coves_map[ped.id]:
|
||||||
|
doc_rows.append((f'COVE{cove.numero_cove}', _estado(cove.cove_descargado)))
|
||||||
|
doc_rows.append((f'ACUSE COVE{cove.numero_cove}', _estado(cove.acuse_cove_descargado)))
|
||||||
|
for edoc in edocs_map[ped.id]:
|
||||||
|
doc_rows.append((f'EDOCUMENTO{edoc.numero_edocument}', _estado(edoc.edocument_descargado)))
|
||||||
|
doc_rows.append((f'ACUSE EDOCUMENTO{edoc.numero_edocument}', _estado(edoc.acuse_descargado)))
|
||||||
|
|
||||||
|
if len(doc_rows) > 1:
|
||||||
|
exp_con_docs += 1
|
||||||
|
if all(e == 'RECUPERADO' for _, e in doc_rows):
|
||||||
|
exp_completos += 1
|
||||||
|
|
||||||
|
n_rows = len(doc_rows)
|
||||||
|
start_row = current_row
|
||||||
|
anio = ped.fecha_pago.year % 100 if ped.fecha_pago else ''
|
||||||
|
base_vals = [
|
||||||
|
anio, ped.aduana or '', ped.patente or '', ped.pedimento or '',
|
||||||
|
ped.pedimento_app or '', ped.clave_pedimento or '',
|
||||||
|
ped.tipo_operacion.tipo if ped.tipo_operacion else '',
|
||||||
|
'SI' if ped.existe_expediente else 'NO',
|
||||||
|
]
|
||||||
|
|
||||||
|
# Sin merge_cells — para datasets grandes merge es O(n^2) y cuelga el proceso.
|
||||||
|
# Los datos base solo se escriben en la primera fila; el resto queda vacío,
|
||||||
|
# visualmente equivalente al merge pero sin el costo de memoria/CPU.
|
||||||
|
for offset, (doc_nombre, doc_est) in enumerate(doc_rows):
|
||||||
|
r = start_row + offset
|
||||||
|
if offset == 0:
|
||||||
|
for col, val in enumerate(base_vals, 1):
|
||||||
|
ws.cell(row=r, column=col, value=val)
|
||||||
|
ws.cell(row=r, column=9, value=doc_nombre)
|
||||||
|
ws.cell(row=r, column=10, value=doc_est)
|
||||||
|
|
||||||
|
current_row += n_rows
|
||||||
|
|
||||||
|
ws.merge_cells(start_row=current_row, start_column=1, end_row=current_row, end_column=TOTAL_COLS)
|
||||||
|
cell = ws.cell(
|
||||||
|
row=current_row, column=1,
|
||||||
|
value=(f'Total de Expedientes= {total_exp} '
|
||||||
|
f'Total De Expedientes Con Documentos= {exp_con_docs} '
|
||||||
|
f'Total De Expedientes Completos= {exp_completos}'),
|
||||||
|
)
|
||||||
|
cell.fill = footer_fill
|
||||||
|
cell.font = Font(bold=True)
|
||||||
|
current_row += 2
|
||||||
|
|
||||||
|
del peds, ped_ids, coves_map, edocs_map, partidas_map, remesa_ped_ids
|
||||||
|
|
||||||
|
for i, w in enumerate([6, 8, 8, 12, 32, 8, 16, 12, 32, 14], 1):
|
||||||
|
ws.column_dimensions[openpyxl.utils.get_column_letter(i)].width = w
|
||||||
|
|
||||||
|
# ── 6. Serializar y subir ─────────────────────────────────────────────
|
||||||
|
logger.info('[reporte_cumplimiento] report=%s serializando Excel...', report_id)
|
||||||
|
publish_task_event(task_id, 'processing', 'Serializando Excel...', progress=88)
|
||||||
|
|
||||||
|
filename = f"reporte_cumplimiento_{report.id}_{timezone.now().strftime('%Y%m%d%H%M%S')}.xlsx"
|
||||||
|
buf = io.BytesIO()
|
||||||
|
wb.save(buf)
|
||||||
|
excel_bytes = buf.getvalue()
|
||||||
|
logger.info('[reporte_cumplimiento] report=%s Excel size=%.1fKB', report_id, len(excel_bytes) / 1024)
|
||||||
|
|
||||||
|
publish_task_event(task_id, 'processing', 'Subiendo a almacenamiento...', progress=93)
|
||||||
|
|
||||||
|
ruta = storage_service.save_report(
|
||||||
|
file=SimpleUploadedFile(
|
||||||
|
name=filename,
|
||||||
|
content=excel_bytes,
|
||||||
|
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
),
|
||||||
|
organizacion_id=org_id,
|
||||||
|
metadata={
|
||||||
|
'report_id': str(report.id),
|
||||||
|
'report_type': 'cumplimiento',
|
||||||
|
'user_id': str(report.user.id) if report.user else None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if ruta:
|
||||||
|
logger.info('[reporte_cumplimiento] report=%s guardado en storage=%s', report_id, ruta)
|
||||||
|
report.file = ruta
|
||||||
|
report.status = 'ready'
|
||||||
else:
|
else:
|
||||||
filename = f"report_{report.id}_{timezone.now().strftime('%Y%m%d%H%M%S')}.csv"
|
_fail('Error al guardar el archivo en almacenamiento (storage retornó None)')
|
||||||
file_path = os.path.join(settings.MEDIA_ROOT, 'reports', filename)
|
return
|
||||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
|
||||||
with open(file_path, 'w', newline='', encoding='utf-8') as f:
|
|
||||||
writer = csv.writer(f)
|
|
||||||
headers = [
|
|
||||||
'aduana', 'patente', 'regimen', 'pedimento', 'pedimento_app', 'clave_pedimento',
|
|
||||||
'tipo_operacion_id', 'contribuyente_id', 'tipo_documento', 'numero_documento', 'estado', 'acuse_estado'
|
|
||||||
]
|
|
||||||
writer.writerow(headers)
|
|
||||||
for ped in pedimentos:
|
|
||||||
for cove in Cove.objects.filter(pedimento=ped):
|
|
||||||
writer.writerow([
|
|
||||||
ped.aduana, ped.patente, ped.regimen, ped.pedimento, ped.pedimento_app,
|
|
||||||
ped.clave_pedimento, ped.tipo_operacion_id, ped.contribuyente_id,
|
|
||||||
'COVE', cove.numero_cove, cove.cove_descargado, cove.acuse_cove_descargado
|
|
||||||
])
|
|
||||||
for edoc in EDocument.objects.filter(pedimento=ped):
|
|
||||||
writer.writerow([
|
|
||||||
ped.aduana, ped.patente, ped.regimen, ped.pedimento, ped.pedimento_app,
|
|
||||||
ped.clave_pedimento, ped.tipo_operacion_id, ped.contribuyente_id,
|
|
||||||
'EDOC', edoc.numero_edocument, edoc.edocument_descargado, edoc.acuse_descargado
|
|
||||||
])
|
|
||||||
for partida in Partida.objects.filter(pedimento=ped):
|
|
||||||
writer.writerow([
|
|
||||||
ped.aduana, ped.patente, ped.regimen, ped.pedimento, ped.pedimento_app,
|
|
||||||
ped.clave_pedimento, ped.tipo_operacion_id, ped.contribuyente_id,
|
|
||||||
'PARTIDA', partida.numero_partida, partida.descargado, ''
|
|
||||||
])
|
|
||||||
# Guardar el archivo en el modelo
|
|
||||||
with open(file_path, 'rb') as f:
|
|
||||||
report.file.save(filename, ContentFile(f.read()), save=True)
|
|
||||||
report.status = 'ready'
|
|
||||||
report.finished_at = timezone.now()
|
report.finished_at = timezone.now()
|
||||||
report.save(update_fields=['status', 'file', 'finished_at'])
|
report.save(update_fields=['status', 'file', 'finished_at', 'error_message'])
|
||||||
except Exception as e:
|
|
||||||
report.status = 'error'
|
resultado = {
|
||||||
report.error_message = str(e)
|
'report_id': str(report.id),
|
||||||
report.finished_at = timezone.now()
|
'total_rfcs': total_rfcs,
|
||||||
report.save(update_fields=['status', 'error_message', 'finished_at'])
|
'total_pedimentos': total_pedimentos,
|
||||||
|
}
|
||||||
|
publish_task_event(task_id, 'completed', 'Reporte generado exitosamente.', progress=100, resultado=resultado)
|
||||||
|
logger.info('[reporte_cumplimiento] report=%s COMPLETADO rfcs=%d pedimentos=%d',
|
||||||
|
report_id, total_rfcs, total_pedimentos)
|
||||||
|
return resultado
|
||||||
|
|
||||||
|
except SoftTimeLimitExceeded:
|
||||||
|
_fail('El reporte tardó más de 10 minutos y fue cancelado. Intenta con un rango de fechas más acotado.')
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
_fail(str(exc), exc=exc)
|
||||||
|
|
||||||
|
|
||||||
|
# ── reporte de control de pedimentos (sin cambios) ────────────────────────────
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def generate_report_control_pedimento(report_id):
|
def generate_report_control_pedimento(report_id):
|
||||||
|
report = None
|
||||||
try:
|
try:
|
||||||
|
|
||||||
report = ReportDocument.objects.get(id=report_id)
|
report = ReportDocument.objects.get(id=report_id)
|
||||||
report.status = 'processing'
|
report.status = 'processing'
|
||||||
report.save(update_fields=['status'])
|
report.save(update_fields=['status'])
|
||||||
filters = report.filters or {}
|
filters = report.filters or {}
|
||||||
|
|
||||||
|
|
||||||
# Construir filtros
|
|
||||||
pedimentos_filters = {}
|
pedimentos_filters = {}
|
||||||
if filters.get('organizacion_id'):
|
if filters.get('organizacion_id'):
|
||||||
pedimentos_filters['organizacion_id'] = filters['organizacion_id']
|
pedimentos_filters['organizacion_id'] = filters['organizacion_id']
|
||||||
@@ -107,15 +388,12 @@ def generate_report_control_pedimento(report_id):
|
|||||||
if filters.get('pedimento_app'):
|
if filters.get('pedimento_app'):
|
||||||
pedimentos_filters['pedimento_app'] = filters['pedimento_app']
|
pedimentos_filters['pedimento_app'] = filters['pedimento_app']
|
||||||
|
|
||||||
# pedimentos por organizacion
|
|
||||||
pedimentos_qs = Pedimento.objects.filter(**pedimentos_filters)
|
pedimentos_qs = Pedimento.objects.filter(**pedimentos_filters)
|
||||||
pedimentos_total = pedimentos_qs.count()
|
pedimentos_total = pedimentos_qs.count()
|
||||||
|
|
||||||
|
|
||||||
pedimento_ids = list(pedimentos_qs.values_list('id', flat=True))
|
pedimento_ids = list(pedimentos_qs.values_list('id', flat=True))
|
||||||
rfcs_raw = list(pedimentos_qs.values_list('agente_aduanal', flat=True))
|
rfcs_raw = list(pedimentos_qs.values_list('agente_aduanal', flat=True))
|
||||||
|
|
||||||
# inicializar totales
|
|
||||||
pedimentos_completos = 0
|
pedimentos_completos = 0
|
||||||
total_documentos = 0
|
total_documentos = 0
|
||||||
documentos_sin_descargar = 0
|
documentos_sin_descargar = 0
|
||||||
@@ -123,15 +401,13 @@ def generate_report_control_pedimento(report_id):
|
|||||||
nombre_organizacion = ''
|
nombre_organizacion = ''
|
||||||
if filters.get('organizacion_id'):
|
if filters.get('organizacion_id'):
|
||||||
try:
|
try:
|
||||||
# Asumo que tienes un modelo Organizacion - ajusta según tu modelo real
|
|
||||||
organizacion = Organizacion.objects.get(id=filters['organizacion_id'])
|
organizacion = Organizacion.objects.get(id=filters['organizacion_id'])
|
||||||
nombre_organizacion = organizacion.nombre # ajusta el campo según tu modelo
|
nombre_organizacion = organizacion.nombre
|
||||||
except Organizacion.DoesNotExist:
|
except Organizacion.DoesNotExist:
|
||||||
nombre_organizacion = f"ID: {filters['organizacion_id']}"
|
nombre_organizacion = f"ID: {filters['organizacion_id']}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
nombre_organizacion = f"Error: {str(e)}"
|
nombre_organizacion = f"Error: {str(e)}"
|
||||||
|
|
||||||
# lista de rfc
|
|
||||||
rfc_list = ', '.join(sorted(set([rfc for rfc in rfcs_raw if rfc])))
|
rfc_list = ', '.join(sorted(set([rfc for rfc in rfcs_raw if rfc])))
|
||||||
|
|
||||||
fecha_inicio = ''
|
fecha_inicio = ''
|
||||||
@@ -146,103 +422,73 @@ def generate_report_control_pedimento(report_id):
|
|||||||
if ultimo_pedimento and ultimo_pedimento.fecha_pago:
|
if ultimo_pedimento and ultimo_pedimento.fecha_pago:
|
||||||
fecha_fin = ultimo_pedimento.fecha_pago.strftime('%Y-%m-%d')
|
fecha_fin = ultimo_pedimento.fecha_pago.strftime('%Y-%m-%d')
|
||||||
|
|
||||||
# Para cada pedimento, verificar si está completo
|
|
||||||
for pedimento in pedimentos_qs:
|
for pedimento in pedimentos_qs:
|
||||||
# Contar documentos de este pedimento
|
|
||||||
docs_pedimento = 0
|
docs_pedimento = 0
|
||||||
docs_pendientes_pedimento = 0
|
docs_pendientes_pedimento = 0
|
||||||
|
|
||||||
# COVES
|
|
||||||
coves_count = Cove.objects.filter(pedimento_id=pedimento.id).count()
|
coves_count = Cove.objects.filter(pedimento_id=pedimento.id).count()
|
||||||
coves_pendientes = Cove.objects.filter(pedimento_id=pedimento.id, cove_descargado=False).count()
|
coves_pendientes = Cove.objects.filter(pedimento_id=pedimento.id, cove_descargado=False).count()
|
||||||
docs_pedimento += coves_count
|
docs_pedimento += coves_count
|
||||||
docs_pendientes_pedimento += coves_pendientes
|
docs_pendientes_pedimento += coves_pendientes
|
||||||
|
|
||||||
# PARTIDAS
|
|
||||||
partidas_count = Partida.objects.filter(pedimento_id=pedimento.id).count()
|
partidas_count = Partida.objects.filter(pedimento_id=pedimento.id).count()
|
||||||
partidas_pendientes = Partida.objects.filter(pedimento_id=pedimento.id, descargado=False).count()
|
partidas_pendientes = Partida.objects.filter(pedimento_id=pedimento.id, descargado=False).count()
|
||||||
docs_pedimento += partidas_count
|
docs_pedimento += partidas_count
|
||||||
docs_pendientes_pedimento += partidas_pendientes
|
docs_pendientes_pedimento += partidas_pendientes
|
||||||
|
|
||||||
# EDOCUMENTS
|
|
||||||
edocs_count = EDocument.objects.filter(pedimento_id=pedimento.id).count()
|
edocs_count = EDocument.objects.filter(pedimento_id=pedimento.id).count()
|
||||||
edocs_pendientes = EDocument.objects.filter(pedimento_id=pedimento.id, edocument_descargado=False).count()
|
edocs_pendientes = EDocument.objects.filter(pedimento_id=pedimento.id, edocument_descargado=False).count()
|
||||||
docs_pedimento += edocs_count
|
docs_pedimento += edocs_count
|
||||||
docs_pendientes_pedimento += edocs_pendientes
|
docs_pendientes_pedimento += edocs_pendientes
|
||||||
|
|
||||||
# Acumular totales
|
|
||||||
total_documentos += docs_pedimento
|
total_documentos += docs_pedimento
|
||||||
documentos_sin_descargar += docs_pendientes_pedimento
|
documentos_sin_descargar += docs_pendientes_pedimento
|
||||||
|
|
||||||
# Si no tiene documentos pendientes, está completo
|
|
||||||
if docs_pendientes_pedimento == 0 and docs_pedimento > 0:
|
if docs_pendientes_pedimento == 0 and docs_pedimento > 0:
|
||||||
pedimentos_completos += 1
|
pedimentos_completos += 1
|
||||||
|
|
||||||
# 3. PORCENTAJE
|
|
||||||
porcentaje_faltantes = (documentos_sin_descargar / total_documentos * 100) if total_documentos > 0 else 0
|
porcentaje_faltantes = (documentos_sin_descargar / total_documentos * 100) if total_documentos > 0 else 0
|
||||||
|
|
||||||
# 4. GENERAR CSV CON DETALLES
|
|
||||||
filename = f"report_{report.id}_{timezone.now().strftime('%Y%m%d%H%M%S')}.csv"
|
filename = f"report_{report.id}_{timezone.now().strftime('%Y%m%d%H%M%S')}.csv"
|
||||||
file_path = os.path.join(settings.MEDIA_ROOT, 'reports', filename)
|
|
||||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.csv', encoding='utf-8', newline='') as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
|
||||||
todas_las_filas = []
|
todas_las_filas = []
|
||||||
|
|
||||||
# Recopilar datos detallados - UNA FILA POR CADA DOCUMENTO
|
|
||||||
for pedimento in pedimentos_qs:
|
for pedimento in pedimentos_qs:
|
||||||
# DATOS BASE DEL PEDIMENTO (se repiten en cada fila)
|
|
||||||
datos_base_pedimento = [
|
datos_base_pedimento = [
|
||||||
pedimento.aduana or '',
|
pedimento.aduana or '',
|
||||||
pedimento.patente or '',
|
pedimento.patente or '',
|
||||||
pedimento.regimen or '',
|
pedimento.regimen or '',
|
||||||
pedimento.pedimento or '', # No. Pedimento (7 dígitos)
|
pedimento.pedimento or '',
|
||||||
pedimento.pedimento_app or '', # No. Pedimento App completo
|
pedimento.pedimento_app or '',
|
||||||
pedimento.clave_pedimento or '',
|
pedimento.clave_pedimento or '',
|
||||||
pedimento.tipo_operacion.tipo if pedimento.tipo_operacion else '',
|
pedimento.tipo_operacion.tipo if pedimento.tipo_operacion else '',
|
||||||
str(pedimento.contribuyente_id) if pedimento.contribuyente_id else ''
|
str(pedimento.contribuyente_id) if pedimento.contribuyente_id else ''
|
||||||
]
|
]
|
||||||
|
|
||||||
# COVES - Una fila por cada COVE
|
|
||||||
coves = Cove.objects.filter(pedimento_id=pedimento.id)
|
coves = Cove.objects.filter(pedimento_id=pedimento.id)
|
||||||
for cove in coves:
|
for cove in coves:
|
||||||
estado = 'VERDADERO' if cove.cove_descargado else 'FALSO'
|
estado = 'VERDADERO' if cove.cove_descargado else 'FALSO'
|
||||||
fila = datos_base_pedimento + [
|
fila = datos_base_pedimento + [cove.numero_cove, 'COVE', estado]
|
||||||
# str(cove.id), # Identificador de documento
|
|
||||||
cove.numero_cove,
|
|
||||||
'COVE', # Tipo de documento
|
|
||||||
estado
|
|
||||||
]
|
|
||||||
todas_las_filas.append(fila)
|
todas_las_filas.append(fila)
|
||||||
|
|
||||||
# PARTIDAS - Una fila por cada Partida
|
|
||||||
partidas = Partida.objects.filter(pedimento_id=pedimento.id)
|
partidas = Partida.objects.filter(pedimento_id=pedimento.id)
|
||||||
for partida in partidas:
|
for partida in partidas:
|
||||||
estado = 'VERDADERO' if partida.descargado else 'FALSO'
|
estado = 'VERDADERO' if partida.descargado else 'FALSO'
|
||||||
fila = datos_base_pedimento + [
|
fila = datos_base_pedimento + [partida.numero_partida, 'PARTIDA', estado]
|
||||||
# str(partida.id),
|
|
||||||
partida.numero_partida,
|
|
||||||
'PARTIDA', # Tipo de documento
|
|
||||||
estado
|
|
||||||
]
|
|
||||||
todas_las_filas.append(fila)
|
todas_las_filas.append(fila)
|
||||||
|
|
||||||
# EDOCUMENTS - Una fila por cada EDocument
|
|
||||||
edocuments = EDocument.objects.filter(pedimento_id=pedimento.id)
|
edocuments = EDocument.objects.filter(pedimento_id=pedimento.id)
|
||||||
for edoc in edocuments:
|
for edoc in edocuments:
|
||||||
estado = 'VERDADERO' if edoc.edocument_descargado else 'FALSO'
|
estado = 'VERDADERO' if edoc.edocument_descargado else 'FALSO'
|
||||||
fila = datos_base_pedimento + [
|
fila = datos_base_pedimento + [edoc.numero_edocument, 'EDOCUMENT', estado]
|
||||||
# str(edoc.id),
|
|
||||||
edoc.numero_edocument,
|
|
||||||
'EDOCUMENT', # Tipo de documento
|
|
||||||
estado
|
|
||||||
]
|
|
||||||
todas_las_filas.append(fila)
|
todas_las_filas.append(fila)
|
||||||
|
|
||||||
# 5. ESCRIBIR ARCHIVO CSV
|
import csv
|
||||||
with open(file_path, 'w', newline='', encoding='utf-8') as f:
|
with open(tmp_path, 'w', newline='', encoding='utf-8') as f:
|
||||||
writer = csv.writer(f)
|
writer = csv.writer(f)
|
||||||
|
|
||||||
# SECCIÓN DE TOTALES
|
|
||||||
writer.writerow(['RESUMEN DEL REPORTE - CONTROL DE PEDIMENTOS'])
|
writer.writerow(['RESUMEN DEL REPORTE - CONTROL DE PEDIMENTOS'])
|
||||||
writer.writerow(['ORGANIZACION:', nombre_organizacion])
|
writer.writerow(['ORGANIZACION:', nombre_organizacion])
|
||||||
writer.writerow([])
|
writer.writerow([])
|
||||||
@@ -255,29 +501,49 @@ def generate_report_control_pedimento(report_id):
|
|||||||
writer.writerow(['LISTA RFC:', rfc_list])
|
writer.writerow(['LISTA RFC:', rfc_list])
|
||||||
writer.writerow([])
|
writer.writerow([])
|
||||||
writer.writerow([])
|
writer.writerow([])
|
||||||
|
|
||||||
# ENCABEZADOS DE DATOS (según requerimiento)
|
|
||||||
headers = [
|
headers = [
|
||||||
'ADUANA', 'PATENTE', 'REGIMEN', 'NO. PEDIMENTO', 'PEDIMENTO_APP',
|
'ADUANA', 'PATENTE', 'REGIMEN', 'NO. PEDIMENTO', 'PEDIMENTO_APP',
|
||||||
'CLAVE_PEDIMENTO', 'TIPO_OPERACION', 'CONTRIBUYENTE_ID',
|
'CLAVE_PEDIMENTO', 'TIPO_OPERACION', 'CONTRIBUYENTE_ID',
|
||||||
'IDENTIFICADOR_DOCUMENTO', 'TIPO_DOCUMENTO', 'ESTADO'
|
'IDENTIFICADOR_DOCUMENTO', 'TIPO_DOCUMENTO', 'ESTADO'
|
||||||
]
|
]
|
||||||
writer.writerow(headers)
|
writer.writerow(headers)
|
||||||
|
|
||||||
# DATOS DETALLADOS
|
|
||||||
for fila in todas_las_filas:
|
for fila in todas_las_filas:
|
||||||
writer.writerow(fila)
|
writer.writerow(fila)
|
||||||
|
|
||||||
|
with open(tmp_path, 'rb') as f:
|
||||||
|
file_content = f.read()
|
||||||
|
|
||||||
with open(file_path, 'rb') as f:
|
uploaded_file = SimpleUploadedFile(
|
||||||
report.file.save(filename, ContentFile(f.read()), save=True)
|
name=filename,
|
||||||
|
content=file_content,
|
||||||
|
content_type='text/csv'
|
||||||
|
)
|
||||||
|
|
||||||
|
ruta = storage_service.save_report(
|
||||||
|
file=uploaded_file,
|
||||||
|
organizacion_id=filters.get('organizacion_id'),
|
||||||
|
metadata={
|
||||||
|
'report_id': str(report.id),
|
||||||
|
'report_type': 'control_pedimento',
|
||||||
|
'user_id': str(report.user.id) if report.user else None
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
|
||||||
|
if ruta:
|
||||||
|
report.file = ruta
|
||||||
|
report.status = 'ready'
|
||||||
|
else:
|
||||||
|
report.status = 'error'
|
||||||
|
report.error_message = 'Error al guardar el archivo en storage'
|
||||||
|
|
||||||
report.status = 'ready'
|
|
||||||
report.finished_at = timezone.now()
|
report.finished_at = timezone.now()
|
||||||
report.save(update_fields=['status', 'file', 'finished_at'])
|
report.save(update_fields=['status', 'file', 'finished_at', 'error_message'])
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
report.status = 'error'
|
if report:
|
||||||
report.error_message = str(e)
|
report.status = 'error'
|
||||||
report.finished_at = timezone.now()
|
report.error_message = str(e)
|
||||||
report.save(update_fields=['status', 'error_message', 'finished_at'])
|
report.finished_at = timezone.now()
|
||||||
|
report.save(update_fields=['status', 'error_message', 'finished_at'])
|
||||||
|
|||||||
@@ -1,3 +1,446 @@
|
|||||||
|
"""
|
||||||
|
Tests para generate_report_document (T2026-04-001).
|
||||||
|
|
||||||
|
Ejecución:
|
||||||
|
python manage.py test api.reports.tests
|
||||||
|
python manage.py test api.reports.tests.TestEstadoHelper
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
import uuid
|
||||||
|
from unittest.mock import MagicMock, call, patch
|
||||||
|
|
||||||
|
import openpyxl
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db.models import Q
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
# Create your tests here.
|
from api.customs.models import Cove, EDocument, Importador, Partida, Pedimento
|
||||||
|
from api.licence.models import Licencia
|
||||||
|
from api.organization.models import Organizacion
|
||||||
|
from api.reports.models import ReportDocument
|
||||||
|
from api.reports.tasks.report_document import (
|
||||||
|
_apply_user_rfc_filter,
|
||||||
|
_estado,
|
||||||
|
generate_report_document,
|
||||||
|
)
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
FAKE_PATH = 'reports/test/reporte.xlsx'
|
||||||
|
|
||||||
|
# ── fixtures ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _licencia(nombre='Plan Test'):
|
||||||
|
return Licencia.objects.create(nombre=nombre, almacenamiento=10)
|
||||||
|
|
||||||
|
|
||||||
|
def _org(nombre='Org Test'):
|
||||||
|
lic = _licencia(f'Lic {nombre}')
|
||||||
|
return Organizacion.objects.create(nombre=nombre, is_active=True, is_verified=True, licencia=lic)
|
||||||
|
|
||||||
|
|
||||||
|
def _user(org, username='tuser', rfcs=None):
|
||||||
|
u = User.objects.create_user(username=username, password='pass', organizacion=org)
|
||||||
|
if rfcs:
|
||||||
|
u.rfc.set(rfcs)
|
||||||
|
return u
|
||||||
|
|
||||||
|
|
||||||
|
def _imp(org, rfc='RFC000000001', nombre='Importador Test'):
|
||||||
|
return Importador.objects.create(rfc=rfc, nombre=nombre, organizacion=org)
|
||||||
|
|
||||||
|
|
||||||
|
def _ped(org, imp=None, num='0000001'):
|
||||||
|
return Pedimento.objects.create(
|
||||||
|
pedimento=num,
|
||||||
|
pedimento_app=f'25-160-3910-{num}',
|
||||||
|
organizacion=org,
|
||||||
|
contribuyente=imp,
|
||||||
|
aduana='160',
|
||||||
|
patente='3910',
|
||||||
|
regimen='ITE',
|
||||||
|
clave_pedimento='A1',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _reporte(user, org_id, extra=None):
|
||||||
|
filtros = {'organizacion_id': str(org_id)}
|
||||||
|
if extra:
|
||||||
|
filtros.update(extra)
|
||||||
|
return ReportDocument.objects.create(
|
||||||
|
user=user, filters=filtros, status='pending', report_type='cumplimiento'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _excel_desde_mock(mock_save):
|
||||||
|
"""Parsea el workbook que recibió storage_service.save_report."""
|
||||||
|
uf = mock_save.call_args[1]['file']
|
||||||
|
return openpyxl.load_workbook(io.BytesIO(uf.read()))
|
||||||
|
|
||||||
|
|
||||||
|
def _docs_col(ws):
|
||||||
|
"""Devuelve {documento: estatus} leyendo columnas 9 y 10 del worksheet."""
|
||||||
|
return {
|
||||||
|
ws.cell(row=r, column=9).value: ws.cell(row=r, column=10).value
|
||||||
|
for r in range(1, ws.max_row + 1)
|
||||||
|
if ws.cell(row=r, column=9).value
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _col1_values(ws):
|
||||||
|
"""Devuelve todos los valores no vacíos de la columna 1."""
|
||||||
|
return [
|
||||||
|
str(ws.cell(row=r, column=1).value)
|
||||||
|
for r in range(1, ws.max_row + 1)
|
||||||
|
if ws.cell(row=r, column=1).value
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── 1. Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestEstadoHelper(TestCase):
|
||||||
|
def test_true_retorna_recuperado(self):
|
||||||
|
self.assertEqual(_estado(True), 'RECUPERADO')
|
||||||
|
|
||||||
|
def test_false_retorna_pendiente(self):
|
||||||
|
self.assertEqual(_estado(False), 'PENDIENTE')
|
||||||
|
|
||||||
|
|
||||||
|
# ── 2. Filtro de RFC por usuario ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestApplyUserRfcFilter(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.org = _org()
|
||||||
|
cls.imp1 = _imp(cls.org, rfc='RFC000000001')
|
||||||
|
cls.imp2 = _imp(cls.org, rfc='RFC000000002')
|
||||||
|
|
||||||
|
def test_sin_rfcs_asignados_sin_filtro_retorna_q_vacio(self):
|
||||||
|
user = _user(self.org, username='u_admin')
|
||||||
|
q = _apply_user_rfc_filter(Q(), user, None)
|
||||||
|
self.assertEqual(str(q), str(Q()))
|
||||||
|
|
||||||
|
def test_sin_rfcs_asignados_con_filtro_explicito_aplica_filtro(self):
|
||||||
|
user = _user(self.org, username='u_admin2')
|
||||||
|
q = _apply_user_rfc_filter(Q(), user, 'RFC000000001')
|
||||||
|
self.assertIn('RFC000000001', str(q))
|
||||||
|
|
||||||
|
def test_con_rfcs_sin_filtro_restringe_a_sus_importadores(self):
|
||||||
|
user = _user(self.org, username='u_imp1', rfcs=[self.imp1])
|
||||||
|
q = _apply_user_rfc_filter(Q(), user, None)
|
||||||
|
self.assertIn('contribuyente', str(q))
|
||||||
|
|
||||||
|
def test_con_rfcs_pide_el_suyo_se_filtra_por_ese_rfc(self):
|
||||||
|
user = _user(self.org, username='u_imp2', rfcs=[self.imp1])
|
||||||
|
q = _apply_user_rfc_filter(Q(), user, 'RFC000000001')
|
||||||
|
self.assertIn('RFC000000001', str(q))
|
||||||
|
|
||||||
|
def test_con_rfcs_pide_ajeno_se_usa_el_suyo_no_el_solicitado(self):
|
||||||
|
user = _user(self.org, username='u_imp3', rfcs=[self.imp1])
|
||||||
|
q = _apply_user_rfc_filter(Q(), user, 'RFC000000002')
|
||||||
|
self.assertNotIn('RFC000000002', str(q))
|
||||||
|
self.assertIn('contribuyente', str(q))
|
||||||
|
|
||||||
|
|
||||||
|
# ── 3. Tarea completa ─────────────────────────────────────────────────────────
|
||||||
|
# Todos los tests en esta clase mockean Redis (publish_task_event) y MinIO
|
||||||
|
# (storage_service.save_report) para no depender de infraestructura externa.
|
||||||
|
|
||||||
|
@patch('api.reports.tasks.report_document.publish_task_event')
|
||||||
|
@patch('api.reports.tasks.report_document.storage_service.save_report',
|
||||||
|
return_value=FAKE_PATH)
|
||||||
|
class TestGenerateReportDocument(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.org = _org('Org Reporte')
|
||||||
|
cls.imp = _imp(cls.org, rfc='MTK8610143000', nombre='Servicios TETAKAWI')
|
||||||
|
cls.user = _user(cls.org, username='rep_user')
|
||||||
|
|
||||||
|
def _run(self, report):
|
||||||
|
generate_report_document.apply(args=[str(report.id)])
|
||||||
|
report.refresh_from_db()
|
||||||
|
|
||||||
|
# ── 3.1 Sin pedimentos ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_sin_pedimentos_genera_excel_vacio_y_status_ready(self, mock_save, mock_pub):
|
||||||
|
report = _reporte(self.user, self.org.id)
|
||||||
|
self._run(report)
|
||||||
|
|
||||||
|
self.assertEqual(report.status, 'ready')
|
||||||
|
self.assertEqual(report.file, FAKE_PATH)
|
||||||
|
mock_save.assert_called_once()
|
||||||
|
|
||||||
|
# El workbook no debe tener datos de RFCs
|
||||||
|
wb = _excel_desde_mock(mock_save)
|
||||||
|
ws = wb.active
|
||||||
|
col1 = _col1_values(ws)
|
||||||
|
self.assertFalse(col1, 'Excel vacío no debe tener contenido en col 1')
|
||||||
|
|
||||||
|
# ── 3.2 RFC aparece en encabezado ─────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_rfc_del_importador_aparece_en_excel(self, mock_save, mock_pub):
|
||||||
|
_ped(self.org, self.imp, '1000001')
|
||||||
|
report = _reporte(self.user, self.org.id)
|
||||||
|
self._run(report)
|
||||||
|
|
||||||
|
self.assertEqual(report.status, 'ready')
|
||||||
|
wb = _excel_desde_mock(mock_save)
|
||||||
|
ws = wb.active
|
||||||
|
col1 = ' '.join(_col1_values(ws))
|
||||||
|
self.assertIn('MTK8610143000', col1)
|
||||||
|
|
||||||
|
# ── 3.3 PEDIMENTO COMPLETO ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_pedimento_completo_recuperado_cuando_existe_expediente(self, mock_save, mock_pub):
|
||||||
|
ped = _ped(self.org, self.imp, '1000002')
|
||||||
|
ped.existe_expediente = True
|
||||||
|
ped.save(update_fields=['existe_expediente'])
|
||||||
|
|
||||||
|
report = _reporte(self.user, self.org.id)
|
||||||
|
self._run(report)
|
||||||
|
|
||||||
|
docs = _docs_col(_excel_desde_mock(mock_save).active)
|
||||||
|
self.assertEqual(docs.get('PEDIMENTO COMPLETO'), 'RECUPERADO')
|
||||||
|
|
||||||
|
def test_pedimento_completo_pendiente_cuando_no_tiene_expediente(self, mock_save, mock_pub):
|
||||||
|
_ped(self.org, self.imp, '1000003') # existe_expediente=False por default
|
||||||
|
|
||||||
|
report = _reporte(self.user, self.org.id)
|
||||||
|
self._run(report)
|
||||||
|
|
||||||
|
docs = _docs_col(_excel_desde_mock(mock_save).active)
|
||||||
|
self.assertEqual(docs.get('PEDIMENTO COMPLETO'), 'PENDIENTE')
|
||||||
|
|
||||||
|
# ── 3.4 Partidas ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_partidas_con_estado_correcto(self, mock_save, mock_pub):
|
||||||
|
ped = _ped(self.org, self.imp, '1000004')
|
||||||
|
Partida.objects.create(
|
||||||
|
pedimento=ped, organizacion=self.org, numero_partida=1, descargado=True
|
||||||
|
)
|
||||||
|
Partida.objects.create(
|
||||||
|
pedimento=ped, organizacion=self.org, numero_partida=2, descargado=False
|
||||||
|
)
|
||||||
|
|
||||||
|
report = _reporte(self.user, self.org.id)
|
||||||
|
self._run(report)
|
||||||
|
|
||||||
|
docs = _docs_col(_excel_desde_mock(mock_save).active)
|
||||||
|
self.assertEqual(docs.get('PARTIDA1'), 'RECUPERADO')
|
||||||
|
self.assertEqual(docs.get('PARTIDA2'), 'PENDIENTE')
|
||||||
|
|
||||||
|
# ── 3.5 COVEs y acuses ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_cove_y_acuse_con_estados_distintos(self, mock_save, mock_pub):
|
||||||
|
ped = _ped(self.org, self.imp, '1000005')
|
||||||
|
Cove.objects.create(
|
||||||
|
pedimento=ped, organizacion=self.org,
|
||||||
|
numero_cove='654001',
|
||||||
|
cove_descargado=True,
|
||||||
|
acuse_cove_descargado=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
report = _reporte(self.user, self.org.id)
|
||||||
|
self._run(report)
|
||||||
|
|
||||||
|
docs = _docs_col(_excel_desde_mock(mock_save).active)
|
||||||
|
self.assertEqual(docs.get('COVE654001'), 'RECUPERADO')
|
||||||
|
self.assertEqual(docs.get('ACUSE COVE654001'), 'PENDIENTE')
|
||||||
|
|
||||||
|
# ── 3.6 EDocumentos y acuses ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_edocumento_y_acuse_con_estados_distintos(self, mock_save, mock_pub):
|
||||||
|
ped = _ped(self.org, self.imp, '1000006')
|
||||||
|
EDocument.objects.create(
|
||||||
|
pedimento=ped, organizacion=self.org,
|
||||||
|
numero_edocument='EDOC001',
|
||||||
|
edocument_descargado=False,
|
||||||
|
acuse_descargado=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
report = _reporte(self.user, self.org.id)
|
||||||
|
self._run(report)
|
||||||
|
|
||||||
|
docs = _docs_col(_excel_desde_mock(mock_save).active)
|
||||||
|
self.assertEqual(docs.get('EDOCUMENTOEDOC001'), 'PENDIENTE')
|
||||||
|
self.assertEqual(docs.get('ACUSE EDOCUMENTOEDOC001'), 'RECUPERADO')
|
||||||
|
|
||||||
|
# ── 3.7 Remesa ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_remesa_recuperada_cuando_document_tipo_15_existe(self, mock_save, mock_pub):
|
||||||
|
"""Pedimento.remesas=True y el query de Document devuelve el pedimento_id."""
|
||||||
|
ped = Pedimento.objects.create(
|
||||||
|
pedimento='1000007', pedimento_app='25-160-3910-1000007',
|
||||||
|
organizacion=self.org, contribuyente=self.imp,
|
||||||
|
aduana='160', patente='3910', remesas=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
report = _reporte(self.user, self.org.id)
|
||||||
|
# Patch solo el query de Document dentro del task
|
||||||
|
with patch('api.reports.tasks.report_document.Document') as MockDoc:
|
||||||
|
mock_qs = MagicMock()
|
||||||
|
mock_qs.values_list.return_value = [ped.id]
|
||||||
|
MockDoc.objects.filter.return_value = mock_qs
|
||||||
|
self._run(report)
|
||||||
|
|
||||||
|
docs = _docs_col(_excel_desde_mock(mock_save).active)
|
||||||
|
self.assertEqual(docs.get('REMESA'), 'RECUPERADO')
|
||||||
|
|
||||||
|
def test_remesa_pendiente_cuando_no_hay_document(self, mock_save, mock_pub):
|
||||||
|
"""Pedimento.remesas=True pero el query de Document devuelve lista vacía."""
|
||||||
|
Pedimento.objects.create(
|
||||||
|
pedimento='1000008', pedimento_app='25-160-3910-1000008',
|
||||||
|
organizacion=self.org, contribuyente=self.imp,
|
||||||
|
aduana='160', patente='3910', remesas=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
report = _reporte(self.user, self.org.id)
|
||||||
|
with patch('api.reports.tasks.report_document.Document') as MockDoc:
|
||||||
|
mock_qs = MagicMock()
|
||||||
|
mock_qs.values_list.return_value = []
|
||||||
|
MockDoc.objects.filter.return_value = mock_qs
|
||||||
|
self._run(report)
|
||||||
|
|
||||||
|
docs = _docs_col(_excel_desde_mock(mock_save).active)
|
||||||
|
self.assertEqual(docs.get('REMESA'), 'PENDIENTE')
|
||||||
|
|
||||||
|
def test_sin_remesa_no_aparece_fila_remesa(self, mock_save, mock_pub):
|
||||||
|
"""Pedimento.remesas=False → no debe aparecer fila REMESA."""
|
||||||
|
_ped(self.org, self.imp, '1000009') # remesas=False por default
|
||||||
|
|
||||||
|
report = _reporte(self.user, self.org.id)
|
||||||
|
self._run(report)
|
||||||
|
|
||||||
|
docs = _docs_col(_excel_desde_mock(mock_save).active)
|
||||||
|
self.assertNotIn('REMESA', docs)
|
||||||
|
|
||||||
|
# ── 3.8 Múltiples RFCs ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_multiples_rfcs_generan_secciones_separadas(self, mock_save, mock_pub):
|
||||||
|
imp2 = _imp(self.org, rfc='TEC140624802', nombre='TEC Importaciones')
|
||||||
|
_ped(self.org, self.imp, '1000010')
|
||||||
|
_ped(self.org, imp2, '1000011')
|
||||||
|
|
||||||
|
report = _reporte(self.user, self.org.id)
|
||||||
|
self._run(report)
|
||||||
|
|
||||||
|
self.assertEqual(report.status, 'ready')
|
||||||
|
contenido = ' '.join(_col1_values(_excel_desde_mock(mock_save).active))
|
||||||
|
self.assertIn('MTK8610143000', contenido)
|
||||||
|
self.assertIn('TEC140624802', contenido)
|
||||||
|
|
||||||
|
# ── 3.9 Restricción por RFC de usuario ───────────────────────────────────
|
||||||
|
|
||||||
|
def test_importador_solo_ve_sus_pedimentos(self, mock_save, mock_pub):
|
||||||
|
imp2 = _imp(self.org, rfc='XYZ999999999', nombre='Externo')
|
||||||
|
_ped(self.org, self.imp, '1000012')
|
||||||
|
_ped(self.org, imp2, '1000013')
|
||||||
|
|
||||||
|
user_restr = _user(self.org, username='u_restr', rfcs=[self.imp])
|
||||||
|
report = _reporte(user_restr, self.org.id)
|
||||||
|
self._run(report)
|
||||||
|
|
||||||
|
self.assertEqual(report.status, 'ready')
|
||||||
|
contenido = ' '.join(_col1_values(_excel_desde_mock(mock_save).active))
|
||||||
|
self.assertIn('MTK8610143000', contenido)
|
||||||
|
self.assertNotIn('XYZ999999999', contenido)
|
||||||
|
|
||||||
|
# ── 3.10 Formato del archivo ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_archivo_descargado_es_xlsx_valido(self, mock_save, mock_pub):
|
||||||
|
_ped(self.org, self.imp, '1000014')
|
||||||
|
report = _reporte(self.user, self.org.id)
|
||||||
|
self._run(report)
|
||||||
|
|
||||||
|
uf = mock_save.call_args[1]['file']
|
||||||
|
self.assertTrue(uf.name.endswith('.xlsx'), f'Esperado .xlsx, recibido: {uf.name}')
|
||||||
|
try:
|
||||||
|
wb = openpyxl.load_workbook(io.BytesIO(uf.read()))
|
||||||
|
self.assertIsNotNone(wb)
|
||||||
|
except Exception as exc:
|
||||||
|
self.fail(f'Excel no es válido: {exc}')
|
||||||
|
|
||||||
|
def test_cabeceras_de_columna_presentes(self, mock_save, mock_pub):
|
||||||
|
_ped(self.org, self.imp, '1000015')
|
||||||
|
report = _reporte(self.user, self.org.id)
|
||||||
|
self._run(report)
|
||||||
|
|
||||||
|
ws = _excel_desde_mock(mock_save).active
|
||||||
|
cabeceras = None
|
||||||
|
for r in range(1, ws.max_row + 1):
|
||||||
|
if ws.cell(row=r, column=1).value == 'Año':
|
||||||
|
cabeceras = [ws.cell(row=r, column=c).value for c in range(1, 11)]
|
||||||
|
break
|
||||||
|
|
||||||
|
self.assertIsNotNone(cabeceras, 'No se encontró la fila de cabeceras')
|
||||||
|
for col in ('Año', 'Aduana', 'Patente', 'Pedimento', 'Documento', 'Estatus'):
|
||||||
|
self.assertIn(col, cabeceras, f'Cabecera "{col}" no encontrada')
|
||||||
|
|
||||||
|
# ── 3.11 Progreso en Redis ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_se_publican_eventos_de_progreso(self, mock_save, mock_pub):
|
||||||
|
_ped(self.org, self.imp, '1000016')
|
||||||
|
report = _reporte(self.user, self.org.id)
|
||||||
|
self._run(report)
|
||||||
|
|
||||||
|
self.assertGreaterEqual(mock_pub.call_count, 4, 'Se esperan mínimo 4 eventos')
|
||||||
|
|
||||||
|
def test_ultimo_evento_es_completed_con_100(self, mock_save, mock_pub):
|
||||||
|
_ped(self.org, self.imp, '1000017')
|
||||||
|
report = _reporte(self.user, self.org.id)
|
||||||
|
self._run(report)
|
||||||
|
|
||||||
|
ultimo = mock_pub.call_args_list[-1]
|
||||||
|
self.assertEqual(ultimo[0][1], 'completed')
|
||||||
|
self.assertEqual(ultimo[1].get('progress'), 100)
|
||||||
|
|
||||||
|
# ── 3.12 Manejo de errores ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_storage_none_deja_status_error(self, mock_save, mock_pub):
|
||||||
|
"""storage_service.save_report retorna None → report queda en error."""
|
||||||
|
mock_save.return_value = None
|
||||||
|
_ped(self.org, self.imp, '1000018')
|
||||||
|
report = _reporte(self.user, self.org.id)
|
||||||
|
self._run(report)
|
||||||
|
|
||||||
|
self.assertEqual(report.status, 'error')
|
||||||
|
self.assertIn('almacenamiento', report.error_message)
|
||||||
|
|
||||||
|
def test_storage_none_publica_evento_failed(self, mock_save, mock_pub):
|
||||||
|
mock_save.return_value = None
|
||||||
|
_ped(self.org, self.imp, '1000019')
|
||||||
|
report = _reporte(self.user, self.org.id)
|
||||||
|
self._run(report)
|
||||||
|
|
||||||
|
statuses = [c[0][1] for c in mock_pub.call_args_list]
|
||||||
|
self.assertIn('failed', statuses)
|
||||||
|
self.assertNotIn('completed', statuses)
|
||||||
|
|
||||||
|
def test_excepcion_guarda_traceback_en_error_message(self, mock_save, mock_pub):
|
||||||
|
"""Una excepción inesperada debe incluir traceback en error_message."""
|
||||||
|
mock_save.side_effect = RuntimeError('Fallo simulado de MinIO')
|
||||||
|
_ped(self.org, self.imp, '1000020')
|
||||||
|
report = _reporte(self.user, self.org.id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
generate_report_document.apply(args=[str(report.id)])
|
||||||
|
except RuntimeError:
|
||||||
|
pass # apply() re-raise la excepción
|
||||||
|
|
||||||
|
report.refresh_from_db()
|
||||||
|
self.assertEqual(report.status, 'error')
|
||||||
|
self.assertIn('Fallo simulado de MinIO', report.error_message)
|
||||||
|
self.assertIn('Traceback', report.error_message)
|
||||||
|
|
||||||
|
def test_excepcion_publica_evento_failed(self, mock_save, mock_pub):
|
||||||
|
mock_save.side_effect = RuntimeError('Error MinIO')
|
||||||
|
_ped(self.org, self.imp, '1000021')
|
||||||
|
report = _reporte(self.user, self.org.id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
generate_report_document.apply(args=[str(report.id)])
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
statuses = [c[0][1] for c in mock_pub.call_args_list]
|
||||||
|
self.assertIn('failed', statuses)
|
||||||
|
|||||||
348
api/reports/tests_datastage.py
Normal file
348
api/reports/tests_datastage.py
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
"""
|
||||||
|
Tests para el reporte DataStage asíncrono (Celery + SSE).
|
||||||
|
|
||||||
|
Ejecución:
|
||||||
|
python manage.py test api.reports.tests_datastage
|
||||||
|
"""
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import openpyxl
|
||||||
|
from django.apps import apps
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db import connection
|
||||||
|
from django.test import TestCase
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from api.licence.models import Licencia
|
||||||
|
from api.organization.models import Organizacion
|
||||||
|
from api.reports.models import ReportDocument
|
||||||
|
from api.reports.tasks.report_datastage import generate_report_datastage
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
FAKE_PATH = 'org_x/reports/datastage_test.xlsx'
|
||||||
|
|
||||||
|
|
||||||
|
# ── fixtures ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _ensure_registro_created_at():
|
||||||
|
"""Las migraciones 0013/0014 de datastage agregan created_at solo en estado
|
||||||
|
(SeparateDatabaseAndState) porque la columna ya existía en la BD real; en la
|
||||||
|
BD de test hay que crearla explícitamente para poder insertar registros."""
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute('ALTER TABLE registro501 ADD COLUMN IF NOT EXISTS created_at timestamptz')
|
||||||
|
cur.execute('ALTER TABLE registro502 ADD COLUMN IF NOT EXISTS created_at timestamptz')
|
||||||
|
|
||||||
|
|
||||||
|
def _org(nombre='Org DataStage'):
|
||||||
|
lic = Licencia.objects.create(nombre=f'Lic {nombre}', almacenamiento=10)
|
||||||
|
return Organizacion.objects.create(nombre=nombre, is_active=True, is_verified=True, licencia=lic)
|
||||||
|
|
||||||
|
|
||||||
|
def _user(org, username='ds_user', superuser=False):
|
||||||
|
if superuser:
|
||||||
|
u = User.objects.create_superuser(username=username, password='pass', email=f'{username}@test.mx')
|
||||||
|
# Superuser JWT requiere active_organization (OrgScopedPermission)
|
||||||
|
u.active_organization = org
|
||||||
|
u.save(update_fields=['active_organization'])
|
||||||
|
return u
|
||||||
|
return User.objects.create_user(username=username, password='pass', organizacion=org)
|
||||||
|
|
||||||
|
|
||||||
|
def _registro501(org, pedimento='1000001', rfc='XAXX010101000', patente='3910'):
|
||||||
|
Registro501 = apps.get_model('datastage', 'Registro501')
|
||||||
|
return Registro501.objects.create(
|
||||||
|
organizacion=org, patente=patente, pedimento=pedimento,
|
||||||
|
seccion_aduanera='160', rfc=rfc,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _registro502(org, pedimento='1000001', patente='3910', transportista='Transportes Test'):
|
||||||
|
Registro502 = apps.get_model('datastage', 'Registro502')
|
||||||
|
return Registro502.objects.create(
|
||||||
|
organizacion=org, patente=patente, pedimento=pedimento,
|
||||||
|
seccion_aduanera='160', nombre_transportista=transportista,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _reporte(user, payload):
|
||||||
|
return ReportDocument.objects.create(
|
||||||
|
user=user, filters=payload, status='pending', report_type='datastage'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _payload_simple(org, fmt='excel', model='Registro501', fields=None):
|
||||||
|
return {
|
||||||
|
'modo': 'simple',
|
||||||
|
'format': fmt,
|
||||||
|
'globalFilters': {'organizacion': str(org.id)},
|
||||||
|
'organizacion_id': str(org.id),
|
||||||
|
'model': model,
|
||||||
|
'fields': fields or ['patente', 'pedimento', 'rfc'],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _payload_multiple(org, fmt='excel'):
|
||||||
|
return {
|
||||||
|
'modo': 'multiple',
|
||||||
|
'format': fmt,
|
||||||
|
'globalFilters': {'organizacion': str(org.id)},
|
||||||
|
'organizacion_id': str(org.id),
|
||||||
|
'models': [
|
||||||
|
{'model': 'Registro501', 'name': 'Datos generales', 'fields': ['rfc', 'patente', 'pedimento']},
|
||||||
|
{'model': 'Registro502', 'name': 'Transporte', 'fields': ['nombre_transportista']},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _archivo_desde_mock(mock_save):
|
||||||
|
"""Devuelve (nombre, bytes) del archivo que recibió storage_service.save_report."""
|
||||||
|
uf = mock_save.call_args[1]['file']
|
||||||
|
return uf.name, uf.read()
|
||||||
|
|
||||||
|
|
||||||
|
# ── 1. Task Celery ────────────────────────────────────────────────────────────
|
||||||
|
# Se mockean Redis (publish_task_event) y MinIO (storage_service.save_report).
|
||||||
|
|
||||||
|
@patch('api.reports.tasks.report_datastage.publish_task_event')
|
||||||
|
@patch('api.reports.tasks.report_datastage.storage_service.save_report',
|
||||||
|
return_value=FAKE_PATH)
|
||||||
|
class TestGenerateReportDatastage(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
_ensure_registro_created_at()
|
||||||
|
cls.org = _org()
|
||||||
|
cls.user = _user(cls.org)
|
||||||
|
|
||||||
|
def _run(self, report):
|
||||||
|
generate_report_datastage.apply(args=[str(report.id)])
|
||||||
|
report.refresh_from_db()
|
||||||
|
|
||||||
|
# ── 1.1 Simple / Excel ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_simple_excel_status_ready_y_archivo_xlsx(self, mock_save, mock_pub):
|
||||||
|
_registro501(self.org)
|
||||||
|
report = _reporte(self.user, _payload_simple(self.org, fmt='excel'))
|
||||||
|
self._run(report)
|
||||||
|
|
||||||
|
self.assertEqual(report.status, 'ready')
|
||||||
|
self.assertEqual(report.file, FAKE_PATH)
|
||||||
|
self.assertIsNotNone(report.finished_at)
|
||||||
|
|
||||||
|
nombre, contenido = _archivo_desde_mock(mock_save)
|
||||||
|
self.assertTrue(nombre.endswith('.xlsx'), f'Esperado .xlsx, recibido: {nombre}')
|
||||||
|
wb = openpyxl.load_workbook(io.BytesIO(contenido))
|
||||||
|
valores = [str(c.value) for row in wb.active.iter_rows() for c in row if c.value]
|
||||||
|
self.assertIn('XAXX010101000', valores)
|
||||||
|
|
||||||
|
# ── 1.2 Simple / CSV ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_simple_csv_status_ready_y_archivo_csv(self, mock_save, mock_pub):
|
||||||
|
_registro501(self.org)
|
||||||
|
report = _reporte(self.user, _payload_simple(self.org, fmt='csv'))
|
||||||
|
self._run(report)
|
||||||
|
|
||||||
|
self.assertEqual(report.status, 'ready')
|
||||||
|
nombre, contenido = _archivo_desde_mock(mock_save)
|
||||||
|
self.assertTrue(nombre.endswith('.csv'), f'Esperado .csv, recibido: {nombre}')
|
||||||
|
|
||||||
|
rows = list(csv.reader(io.StringIO(contenido.decode('utf-8'))))
|
||||||
|
self.assertEqual(rows[0], ['patente', 'pedimento', 'rfc'])
|
||||||
|
self.assertIn('XAXX010101000', rows[1])
|
||||||
|
|
||||||
|
# ── 1.3 Aislamiento por organización ──────────────────────────────────────
|
||||||
|
|
||||||
|
def test_simple_no_incluye_datos_de_otra_organizacion(self, mock_save, mock_pub):
|
||||||
|
_registro501(self.org, rfc='XAXX010101000')
|
||||||
|
otra_org = _org('Otra Org')
|
||||||
|
_registro501(otra_org, pedimento='9999999', rfc='XEXX010101000')
|
||||||
|
|
||||||
|
report = _reporte(self.user, _payload_simple(self.org, fmt='csv'))
|
||||||
|
self._run(report)
|
||||||
|
|
||||||
|
_, contenido = _archivo_desde_mock(mock_save)
|
||||||
|
texto = contenido.decode('utf-8')
|
||||||
|
self.assertIn('XAXX010101000', texto)
|
||||||
|
self.assertNotIn('XEXX010101000', texto)
|
||||||
|
|
||||||
|
# ── 1.4 Múltiple / Excel ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_multiple_excel_combina_modelos_por_llave(self, mock_save, mock_pub):
|
||||||
|
_registro501(self.org)
|
||||||
|
_registro502(self.org)
|
||||||
|
report = _reporte(self.user, _payload_multiple(self.org, fmt='excel'))
|
||||||
|
self._run(report)
|
||||||
|
|
||||||
|
self.assertEqual(report.status, 'ready')
|
||||||
|
nombre, contenido = _archivo_desde_mock(mock_save)
|
||||||
|
self.assertTrue(nombre.endswith('datastage_reporte.xlsx'), f'Nombre inesperado: {nombre}')
|
||||||
|
|
||||||
|
wb = openpyxl.load_workbook(io.BytesIO(contenido))
|
||||||
|
valores = [str(c.value) for row in wb.active.iter_rows() for c in row if c.value]
|
||||||
|
# Campos de ambos modelos en la misma hoja (prefijados por modelo)
|
||||||
|
self.assertIn('Registro501_rfc', valores)
|
||||||
|
self.assertIn('Registro502_nombre_transportista', valores)
|
||||||
|
self.assertIn('XAXX010101000', valores)
|
||||||
|
self.assertIn('Transportes Test', valores)
|
||||||
|
|
||||||
|
# ── 1.5 Múltiple / CSV ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_multiple_csv_combina_modelos(self, mock_save, mock_pub):
|
||||||
|
_registro501(self.org)
|
||||||
|
_registro502(self.org)
|
||||||
|
report = _reporte(self.user, _payload_multiple(self.org, fmt='csv'))
|
||||||
|
self._run(report)
|
||||||
|
|
||||||
|
self.assertEqual(report.status, 'ready')
|
||||||
|
nombre, contenido = _archivo_desde_mock(mock_save)
|
||||||
|
self.assertTrue(nombre.endswith('datastage_reporte.csv'), f'Nombre inesperado: {nombre}')
|
||||||
|
texto = contenido.decode('utf-8')
|
||||||
|
self.assertIn('Registro501_rfc', texto)
|
||||||
|
self.assertIn('Transportes Test', texto)
|
||||||
|
|
||||||
|
# ── 1.6 Sin datos ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_multiple_sin_datos_genera_archivo_sin_datos_y_ready(self, mock_save, mock_pub):
|
||||||
|
report = _reporte(self.user, _payload_multiple(self.org, fmt='excel'))
|
||||||
|
self._run(report)
|
||||||
|
|
||||||
|
self.assertEqual(report.status, 'ready')
|
||||||
|
nombre, _ = _archivo_desde_mock(mock_save)
|
||||||
|
self.assertIn('sin_datos', nombre)
|
||||||
|
|
||||||
|
# ── 1.7 Payload inválido ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_modelo_inexistente_marca_error_y_publica_failed(self, mock_save, mock_pub):
|
||||||
|
report = _reporte(self.user, _payload_simple(self.org, model='NoExiste'))
|
||||||
|
self._run(report)
|
||||||
|
|
||||||
|
self.assertEqual(report.status, 'error')
|
||||||
|
self.assertIn('NoExiste', report.error_message)
|
||||||
|
statuses = [c[0][1] for c in mock_pub.call_args_list]
|
||||||
|
self.assertIn('failed', statuses)
|
||||||
|
self.assertNotIn('completed', statuses)
|
||||||
|
|
||||||
|
def test_payload_sin_model_marca_error(self, mock_save, mock_pub):
|
||||||
|
payload = _payload_simple(self.org)
|
||||||
|
del payload['model']
|
||||||
|
report = _reporte(self.user, payload)
|
||||||
|
self._run(report)
|
||||||
|
|
||||||
|
self.assertEqual(report.status, 'error')
|
||||||
|
mock_save.assert_not_called()
|
||||||
|
|
||||||
|
# ── 1.8 Eventos de progreso ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_ultimo_evento_es_completed_con_100_y_resultado(self, mock_save, mock_pub):
|
||||||
|
_registro501(self.org)
|
||||||
|
report = _reporte(self.user, _payload_simple(self.org))
|
||||||
|
self._run(report)
|
||||||
|
|
||||||
|
ultimo = mock_pub.call_args_list[-1]
|
||||||
|
self.assertEqual(ultimo[0][1], 'completed')
|
||||||
|
self.assertEqual(ultimo[1].get('progress'), 100)
|
||||||
|
resultado = ultimo[1].get('resultado')
|
||||||
|
self.assertEqual(resultado['report_id'], str(report.id))
|
||||||
|
self.assertEqual(resultado['total_registros'], 1)
|
||||||
|
|
||||||
|
def test_se_publican_eventos_de_progreso(self, mock_save, mock_pub):
|
||||||
|
_registro501(self.org)
|
||||||
|
report = _reporte(self.user, _payload_simple(self.org))
|
||||||
|
self._run(report)
|
||||||
|
|
||||||
|
self.assertGreaterEqual(mock_pub.call_count, 4, 'Se esperan mínimo 4 eventos')
|
||||||
|
|
||||||
|
# ── 1.9 Storage falla ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_storage_none_deja_status_error_y_failed(self, mock_save, mock_pub):
|
||||||
|
mock_save.return_value = None
|
||||||
|
_registro501(self.org)
|
||||||
|
report = _reporte(self.user, _payload_simple(self.org))
|
||||||
|
self._run(report)
|
||||||
|
|
||||||
|
self.assertEqual(report.status, 'error')
|
||||||
|
self.assertIn('almacenamiento', report.error_message)
|
||||||
|
statuses = [c[0][1] for c in mock_pub.call_args_list]
|
||||||
|
self.assertIn('failed', statuses)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 2. Vista (encolado 202) ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestExportDataStageView202(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.org = _org('Org Vista')
|
||||||
|
cls.user = _user(cls.org, username='vista_admin', superuser=True)
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
|
def _post(self, body):
|
||||||
|
with patch('api.reports.views.generate_report_datastage.delay',
|
||||||
|
return_value=MagicMock(id='fake-task-id')) as mock_delay:
|
||||||
|
res = self.client.post('/api/v1/reports/exportmodel/datastage/', body, format='json')
|
||||||
|
return res, mock_delay
|
||||||
|
|
||||||
|
def test_post_simple_responde_202_con_task_y_report(self):
|
||||||
|
body = {
|
||||||
|
'modo': 'simple', 'format': 'excel',
|
||||||
|
'globalFilters': {'organizacion': str(self.org.id)},
|
||||||
|
'model': 'Registro501', 'fields': ['patente', 'pedimento'],
|
||||||
|
}
|
||||||
|
res, mock_delay = self._post(body)
|
||||||
|
|
||||||
|
self.assertEqual(res.status_code, 202)
|
||||||
|
self.assertEqual(res.data['task_id'], 'fake-task-id')
|
||||||
|
self.assertEqual(res.data['status'], 'pending')
|
||||||
|
|
||||||
|
report = ReportDocument.objects.get(id=res.data['report_id'])
|
||||||
|
self.assertEqual(report.report_type, 'datastage')
|
||||||
|
self.assertEqual(report.user_id, self.user.id)
|
||||||
|
self.assertEqual(report.filters['organizacion_id'], str(self.org.id))
|
||||||
|
mock_delay.assert_called_once_with(report.id)
|
||||||
|
|
||||||
|
def test_post_multiple_persiste_models_en_filters(self):
|
||||||
|
body = {
|
||||||
|
'modo': 'multiple', 'format': 'csv',
|
||||||
|
'globalFilters': {'organizacion': str(self.org.id)},
|
||||||
|
'models': [{'model': 'Registro501', 'fields': ['rfc']}],
|
||||||
|
}
|
||||||
|
res, _ = self._post(body)
|
||||||
|
|
||||||
|
self.assertEqual(res.status_code, 202)
|
||||||
|
report = ReportDocument.objects.get(id=res.data['report_id'])
|
||||||
|
self.assertEqual(report.filters['modo'], 'multiple')
|
||||||
|
self.assertEqual(report.filters['models'][0]['model'], 'Registro501')
|
||||||
|
|
||||||
|
def test_post_simple_sin_fields_responde_400(self):
|
||||||
|
body = {
|
||||||
|
'modo': 'simple', 'format': 'excel',
|
||||||
|
'globalFilters': {'organizacion': str(self.org.id)},
|
||||||
|
'model': 'Registro501',
|
||||||
|
}
|
||||||
|
res, mock_delay = self._post(body)
|
||||||
|
self.assertEqual(res.status_code, 400)
|
||||||
|
mock_delay.assert_not_called()
|
||||||
|
self.assertFalse(ReportDocument.objects.filter(report_type='datastage').exists())
|
||||||
|
|
||||||
|
def test_post_multiple_sin_models_responde_400(self):
|
||||||
|
body = {
|
||||||
|
'modo': 'multiple', 'format': 'excel',
|
||||||
|
'globalFilters': {'organizacion': str(self.org.id)},
|
||||||
|
}
|
||||||
|
res, mock_delay = self._post(body)
|
||||||
|
self.assertEqual(res.status_code, 400)
|
||||||
|
mock_delay.assert_not_called()
|
||||||
|
|
||||||
|
def test_post_modelo_inexistente_responde_404(self):
|
||||||
|
body = {
|
||||||
|
'modo': 'simple', 'format': 'excel',
|
||||||
|
'globalFilters': {'organizacion': str(self.org.id)},
|
||||||
|
'model': 'NoExiste', 'fields': ['x'],
|
||||||
|
}
|
||||||
|
res, mock_delay = self._post(body)
|
||||||
|
self.assertEqual(res.status_code, 404)
|
||||||
|
mock_delay.assert_not_called()
|
||||||
1111
api/reports/views.py
1111
api/reports/views.py
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user