feat: FK polimorfica Document -> {partida, cove, edocument} + backfill (T2025-09-004)
Reemplaza el matching fragil por nombre de archivo con FK reales: - 3 FK nullables (CASCADE) en Document; resolucion central en save() por document_type + nombre (core.document_links), cubre toda ruta de creacion incluida la ingesta del microservicio; set explicito en create_vu_record. - Comando backfill_document_links (idempotente, dry-run) para filas existentes. - Lectura/descarga/borrado SIEMPRE por la FK (id); el nombre solo ESTABLECE la FK en save()/backfill. Prefetch con select_related(pedimento, fuente) sin N+1. - Migraciones: 0004 (campos), 0005 (indices CONCURRENTLY IF NOT EXISTS, idempotente via SeparateDatabaseAndState), 0006 (ANALYZE document para estadisticas del planner). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
37
CHANGELOG.md
Normal file
37
CHANGELOG.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# 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/frontend: sin cambios — el microservicio ya embebe
|
||||||
|
el número en el nombre del archivo y el backend resuelve la FK al crear el documento)
|
||||||
|
- **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).
|
||||||
|
- 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).
|
||||||
|
- **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.
|
||||||
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
|
||||||
|
)
|
||||||
@@ -11,10 +11,8 @@ from api.customs.models import (
|
|||||||
)
|
)
|
||||||
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
|
||||||
from core.partida_docs import es_doc_de_partida
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -52,32 +50,16 @@ class PartidaSerializer(serializers.ModelSerializer):
|
|||||||
documentos = serializers.SerializerMethodField()
|
documentos = serializers.SerializerMethodField()
|
||||||
|
|
||||||
def get_documentos(self, obj):
|
def get_documentos(self, obj):
|
||||||
if not obj or not getattr(obj, 'pedimento', None) or not getattr(obj, 'numero_partida', None):
|
if not obj:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# El matching documento→partida se hace por nombre de archivo con
|
# Documentos de respuesta de la partida (tipo 1) vía la FK real
|
||||||
# frontera real (core.partida_docs); document_type_id=1 son los
|
# document.partida. 'documentos_vu' lo precarga el ViewSet con prefetch;
|
||||||
# documentos de respuesta de partida (excluye REQUEST/ERROR 17/18).
|
# si no está, se consulta directo (retrieve u otros callers).
|
||||||
mapa = self.context.get('docs_por_partida')
|
docs = getattr(obj, 'documentos_vu', None)
|
||||||
if mapa is not None:
|
if docs is None:
|
||||||
# Camino optimizado: la vista precargó el mapa de la página.
|
docs = list(obj.documents.filter(document_type_id=1).select_related('pedimento', 'fuente'))
|
||||||
docs = mapa.get((obj.pedimento_id, obj.numero_partida), [])
|
|
||||||
else:
|
|
||||||
# Fallback (retrieve u otros callers): una consulta por partida.
|
|
||||||
qs = Document.objects.filter(
|
|
||||||
pedimento=obj.pedimento,
|
|
||||||
document_type_id=1,
|
|
||||||
).select_related('pedimento') # evita N+1 en DocumentSerializer.get_pedimento_numero
|
|
||||||
app = obj.pedimento.pedimento_app
|
|
||||||
docs = [d for d in qs if es_doc_de_partida(d.archivo.name, app, obj.numero_partida)]
|
|
||||||
|
|
||||||
org_id = getattr(obj, 'organizacion_id', None)
|
|
||||||
if org_id:
|
|
||||||
docs = [d for d in docs if d.organizacion_id == org_id]
|
|
||||||
|
|
||||||
return DocumentSerializer(docs, many=True, context=self.context).data
|
return DocumentSerializer(docs, many=True, context=self.context).data
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("get_documentos partida %s: %s", getattr(obj, 'id', '?'), e)
|
logger.warning("get_documentos partida %s: %s", getattr(obj, 'id', '?'), e)
|
||||||
return []
|
return []
|
||||||
@@ -170,43 +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'))
|
||||||
# excluir solo request (21, 25); errores (22, 26) se incluyen para detección en frontend
|
return DocumentSerializer(docs, many=True, context=self.context).data
|
||||||
qs = Document.objects.filter(
|
except Exception as e:
|
||||||
pedimento=obj.pedimento,
|
logger.warning("get_documentos edocument %s: %s", getattr(obj, 'id', '?'), e)
|
||||||
archivo__icontains=numero,
|
|
||||||
).exclude(document_type_id__in=[21, 25])
|
|
||||||
|
|
||||||
# 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:
|
||||||
@@ -262,39 +219,18 @@ class CoveSerializer(serializers.ModelSerializer):
|
|||||||
return attrs
|
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:
|
||||||
# Excluir solo request (19, 23); errores (20, 24) se incluyen para detección en frontend
|
docs = list(obj.documents.exclude(document_type_id__in=[19, 23]).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 cove %s: %s", getattr(obj, 'id', '?'), e)
|
||||||
).exclude(document_type_id__in=[19, 23])
|
|
||||||
|
|
||||||
# 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):
|
||||||
|
|||||||
@@ -238,8 +238,7 @@ from types import SimpleNamespace
|
|||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
from django.test import TestCase, SimpleTestCase
|
from django.test import TestCase, SimpleTestCase
|
||||||
from core.partida_docs import es_doc_de_partida, patron_regex_partida
|
from core.partida_docs import es_doc_de_partida, patron_regex_partida
|
||||||
from api.customs.serializers import PartidaSerializer
|
from api.customs.serializers import PartidaSerializer, CoveSerializer, EDocumentSerializer
|
||||||
from api.customs.views import PartidaViewSet
|
|
||||||
from api.customs.models import Partida
|
from api.customs.models import Partida
|
||||||
from api.record.models import Document, DocumentType
|
from api.record.models import Document, DocumentType
|
||||||
|
|
||||||
@@ -615,24 +614,16 @@ class PartidaDocumentosSerializerTests(TestCase):
|
|||||||
data = PartidaSerializer(self.p1).data
|
data = PartidaSerializer(self.p1).data
|
||||||
self.assertEqual(len(data["documentos"]), 1)
|
self.assertEqual(len(data["documentos"]), 1)
|
||||||
|
|
||||||
def test_get_documentos_mismo_resultado_con_y_sin_prefetch(self):
|
def test_get_documentos_via_fk(self):
|
||||||
self._doc(f"vu_PT_{self.APP}_1.xml")
|
# 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")
|
self._doc(f"vu_PT_{self.APP}_11.xml")
|
||||||
|
d1.refresh_from_db()
|
||||||
vs = PartidaViewSet()
|
self.assertEqual(d1.partida_id, self.p1.id) # FK ligada en save()
|
||||||
mapa = vs._mapa_docs_partida([self.p1, self.p11])
|
data = PartidaSerializer(self.p1).data
|
||||||
ids_p1 = {d.id for d in mapa[(self.pedimento.id, 1)]}
|
self.assertEqual(len(data["documentos"]), 1)
|
||||||
ids_p11 = {d.id for d in mapa[(self.pedimento.id, 11)]}
|
self.assertIn(f"vu_PT_{self.APP}_1.xml", self._blob(data))
|
||||||
self.assertEqual(len(ids_p1), 1)
|
self.assertNotIn("_11", self._blob(data))
|
||||||
self.assertEqual(len(ids_p11), 1)
|
|
||||||
self.assertTrue(ids_p1.isdisjoint(ids_p11))
|
|
||||||
|
|
||||||
sin_ctx = PartidaSerializer(self.p1).data
|
|
||||||
con_ctx = PartidaSerializer(self.p1, context={"docs_por_partida": mapa}).data
|
|
||||||
self.assertEqual(len(sin_ctx["documentos"]), 1)
|
|
||||||
self.assertEqual(len(con_ctx["documentos"]), 1)
|
|
||||||
self.assertIn(f"vu_PT_{self.APP}_1.xml", self._blob(con_ctx))
|
|
||||||
self.assertNotIn("_11", self._blob(con_ctx))
|
|
||||||
|
|
||||||
def test_patron_regex_partida_en_bd(self):
|
def test_patron_regex_partida_en_bd(self):
|
||||||
d1 = self._doc(f"vu_PT_{self.APP}_1")
|
d1 = self._doc(f"vu_PT_{self.APP}_1")
|
||||||
@@ -648,14 +639,144 @@ class PartidaDocumentosSerializerTests(TestCase):
|
|||||||
self.assertEqual(ids, {d1.id, d2.id, d3.id})
|
self.assertEqual(ids, {d1.id, d2.id, d3.id})
|
||||||
self.assertNotIn(d_otro.id, ids)
|
self.assertNotIn(d_otro.id, ids)
|
||||||
|
|
||||||
def test_mapa_docs_partida_es_una_sola_consulta(self):
|
def test_prefetch_documentos_vu_evita_n_plus_1(self):
|
||||||
# documentos para varias partidas del mismo pedimento
|
from django.db.models import Prefetch
|
||||||
self._doc(f"vu_PT_{self.APP}_1.xml")
|
self._doc(f"vu_PT_{self.APP}_1.xml")
|
||||||
self._doc(f"vu_PT_{self.APP}_11.xml")
|
self._doc(f"vu_PT_{self.APP}_11.xml")
|
||||||
partidas = list(
|
prefetch = Prefetch(
|
||||||
Partida.objects.filter(pedimento=self.pedimento).select_related("pedimento")
|
'documents',
|
||||||
|
queryset=Document.objects.filter(document_type_id=1).select_related('pedimento'),
|
||||||
|
to_attr='documentos_vu',
|
||||||
)
|
)
|
||||||
vs = PartidaViewSet()
|
partidas = list(
|
||||||
# Una sola consulta sin importar cuántas partidas (evita el N+1).
|
Partida.objects.filter(pedimento=self.pedimento)
|
||||||
with self.assertNumQueries(1):
|
.order_by('numero_partida').prefetch_related(prefetch)
|
||||||
vs._mapa_docs_partida(partidas)
|
)
|
||||||
|
# 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)
|
||||||
|
|||||||
@@ -60,8 +60,7 @@ from django.core.files.base import ContentFile
|
|||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from rest_framework.parsers import MultiPartParser, FormParser
|
from rest_framework.parsers import MultiPartParser, FormParser
|
||||||
from api.record.models import Document, DocumentType, Fuente
|
from api.record.models import Document, DocumentType, Fuente
|
||||||
from core.partida_docs import es_doc_de_partida
|
from django.db.models import Prefetch
|
||||||
from collections import defaultdict
|
|
||||||
from unicodedata import normalize
|
from unicodedata import normalize
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@@ -2334,14 +2333,21 @@ class PartidaViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
|
# Precarga los documentos de respuesta (tipo 1) de cada partida vía la FK
|
||||||
|
# real document.partida, para que get_documentos no genere N+1.
|
||||||
|
prefetch_docs = Prefetch(
|
||||||
|
'documents',
|
||||||
|
queryset=Document.objects.filter(document_type_id=1).select_related('pedimento', 'fuente'),
|
||||||
|
to_attr='documentos_vu',
|
||||||
|
)
|
||||||
if is_internal_service_request(self.request):
|
if is_internal_service_request(self.request):
|
||||||
return Partida.objects.all()
|
return Partida.objects.all().prefetch_related(prefetch_docs)
|
||||||
if not user_has_permission(user, 'partidas.view'):
|
if not user_has_permission(user, 'partidas.view'):
|
||||||
return Partida.objects.none()
|
return Partida.objects.none()
|
||||||
org = get_org_context(user)
|
org = get_org_context(user)
|
||||||
if not org:
|
if not org:
|
||||||
return Partida.objects.none()
|
return Partida.objects.none()
|
||||||
qs = Partida.objects.filter(pedimento__organizacion=org)
|
qs = Partida.objects.filter(pedimento__organizacion=org).prefetch_related(prefetch_docs)
|
||||||
# Misma precedencia que los mixins de filtrado: superuser y roles
|
# Misma precedencia que los mixins de filtrado: superuser y roles
|
||||||
# operativos ven todo lo de su org; is_importador no los degrada.
|
# operativos ven todo lo de su org; is_importador no los degrada.
|
||||||
if (
|
if (
|
||||||
@@ -2356,41 +2362,6 @@ class PartidaViewSet(viewsets.ModelViewSet):
|
|||||||
return qs.filter(pedimento__contribuyente__in=user.rfc.all())
|
return qs.filter(pedimento__contribuyente__in=user.rfc.all())
|
||||||
return Partida.objects.none()
|
return Partida.objects.none()
|
||||||
|
|
||||||
def list(self, request, *args, **kwargs):
|
|
||||||
# Precarga los documentos de la página en una sola consulta para evitar
|
|
||||||
# el N+1 de get_documentos (una consulta regex por cada partida).
|
|
||||||
queryset = self.filter_queryset(self.get_queryset()).select_related('pedimento')
|
|
||||||
page = self.paginate_queryset(queryset)
|
|
||||||
objetos = page if page is not None else list(queryset)
|
|
||||||
ctx = self.get_serializer_context()
|
|
||||||
ctx['docs_por_partida'] = self._mapa_docs_partida(objetos)
|
|
||||||
serializer = self.get_serializer(objetos, many=True, context=ctx)
|
|
||||||
if page is not None:
|
|
||||||
return self.get_paginated_response(serializer.data)
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
def _mapa_docs_partida(self, partidas):
|
|
||||||
"""Asigna los documentos de respuesta (tipo 1) de los pedimentos de la
|
|
||||||
página a cada partida por nombre de archivo, en memoria.
|
|
||||||
Devuelve {(pedimento_id, numero_partida): [Document, ...]}."""
|
|
||||||
ped_ids = {p.pedimento_id for p in partidas}
|
|
||||||
if not ped_ids:
|
|
||||||
return {}
|
|
||||||
docs_por_ped = defaultdict(list)
|
|
||||||
qs = Document.objects.filter(
|
|
||||||
pedimento_id__in=ped_ids, document_type_id=1,
|
|
||||||
).select_related('pedimento')
|
|
||||||
for d in qs:
|
|
||||||
docs_por_ped[d.pedimento_id].append(d)
|
|
||||||
mapa = {}
|
|
||||||
for p in partidas:
|
|
||||||
app = p.pedimento.pedimento_app
|
|
||||||
mapa[(p.pedimento_id, p.numero_partida)] = [
|
|
||||||
d for d in docs_por_ped.get(p.pedimento_id, [])
|
|
||||||
if es_doc_de_partida(d.archivo.name, app, p.numero_partida)
|
|
||||||
]
|
|
||||||
return mapa
|
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
if is_internal_service_request(self.request):
|
if is_internal_service_request(self.request):
|
||||||
serializer.save()
|
serializer.save()
|
||||||
@@ -2633,7 +2604,14 @@ class ViewSetEDocument(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
|
|||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
if not user_has_permission(self.request.user, 'edocuments.view'):
|
if not user_has_permission(self.request.user, 'edocuments.view'):
|
||||||
return EDocument.objects.none()
|
return EDocument.objects.none()
|
||||||
return self.get_queryset_filtrado_por_organizacion()
|
# Precarga documentos del e-doc (incluye acuse; excluye REQUEST 21/25)
|
||||||
|
# vía la FK document.edocument, para que get_documentos no genere N+1.
|
||||||
|
prefetch_docs = Prefetch(
|
||||||
|
'documents',
|
||||||
|
queryset=Document.objects.exclude(document_type_id__in=[21, 25]).select_related('pedimento', 'fuente'),
|
||||||
|
to_attr='documentos_vu',
|
||||||
|
)
|
||||||
|
return self.get_queryset_filtrado_por_organizacion().prefetch_related(prefetch_docs)
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
if is_internal_service_request(self.request):
|
if is_internal_service_request(self.request):
|
||||||
@@ -2801,7 +2779,14 @@ class ViewSetCove(viewsets.ModelViewSet, OrganizacionFiltradaMixin):
|
|||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
if not user_has_permission(self.request.user, 'coves.view'):
|
if not user_has_permission(self.request.user, 'coves.view'):
|
||||||
return Cove.objects.none()
|
return Cove.objects.none()
|
||||||
return self.get_queryset_filtrado_por_organizacion()
|
# Precarga documentos del cove (incluye acuse cove; excluye REQUEST 19/23)
|
||||||
|
# vía la FK document.cove, para que get_documentos no genere N+1.
|
||||||
|
prefetch_docs = Prefetch(
|
||||||
|
'documents',
|
||||||
|
queryset=Document.objects.exclude(document_type_id__in=[19, 23]).select_related('pedimento', 'fuente'),
|
||||||
|
to_attr='documentos_vu',
|
||||||
|
)
|
||||||
|
return self.get_queryset_filtrado_por_organizacion().prefetch_related(prefetch_docs)
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
if is_internal_service_request(self.request):
|
if is_internal_service_request(self.request):
|
||||||
|
|||||||
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)
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ from core.permissions import (
|
|||||||
user_has_permission,
|
user_has_permission,
|
||||||
IsInternalService,
|
IsInternalService,
|
||||||
)
|
)
|
||||||
from core.partida_docs import patron_regex_partida
|
from core.document_links import ids_documentos_entidad
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -725,14 +725,7 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
|
|||||||
# incluir_legacy=False: el borrado es destructivo, no se elimina por match difuso.
|
# incluir_legacy=False: el borrado es destructivo, no se elimina por match difuso.
|
||||||
doc_ids = []
|
doc_ids = []
|
||||||
for partida in partidas:
|
for partida in partidas:
|
||||||
docs = Document.objects.filter(
|
doc_ids.extend(ids_documentos_entidad(partida, 'partida'))
|
||||||
pedimento_id=partida.pedimento_id,
|
|
||||||
archivo__iregex=patron_regex_partida(
|
|
||||||
partida.pedimento.pedimento_app, partida.numero_partida,
|
|
||||||
incluir_legacy=False,
|
|
||||||
),
|
|
||||||
).values_list('id', flat=True)
|
|
||||||
doc_ids.extend(docs)
|
|
||||||
|
|
||||||
queryset = self.get_queryset()
|
queryset = self.get_queryset()
|
||||||
existing_documents = queryset.filter(id__in=doc_ids)
|
existing_documents = queryset.filter(id__in=doc_ids)
|
||||||
@@ -863,11 +856,7 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
|
|||||||
# Buscar documentos que contengan el numero_cove en el nombre de archivo
|
# Buscar documentos que contengan el numero_cove en el nombre de archivo
|
||||||
doc_ids = []
|
doc_ids = []
|
||||||
for cove in coves:
|
for cove in coves:
|
||||||
docs = Document.objects.filter(
|
doc_ids.extend(ids_documentos_entidad(cove, 'cove'))
|
||||||
pedimento_id=cove.pedimento.id,
|
|
||||||
archivo__icontains=cove.numero_cove
|
|
||||||
).values_list('id', flat=True)
|
|
||||||
doc_ids.extend(docs)
|
|
||||||
|
|
||||||
queryset = self.get_queryset()
|
queryset = self.get_queryset()
|
||||||
existing_documents = queryset.filter(id__in=doc_ids)
|
existing_documents = queryset.filter(id__in=doc_ids)
|
||||||
@@ -996,11 +985,7 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
|
|||||||
# Buscar documentos que contengan el numero_edocument en el nombre de archivo
|
# Buscar documentos que contengan el numero_edocument en el nombre de archivo
|
||||||
doc_ids = []
|
doc_ids = []
|
||||||
for edoc in edocs:
|
for edoc in edocs:
|
||||||
docs = Document.objects.filter(
|
doc_ids.extend(ids_documentos_entidad(edoc, 'edocument'))
|
||||||
pedimento_id=edoc.pedimento.id,
|
|
||||||
archivo__icontains=edoc.numero_edocument
|
|
||||||
).values_list('id', flat=True)
|
|
||||||
doc_ids.extend(docs)
|
|
||||||
|
|
||||||
queryset = self.get_queryset()
|
queryset = self.get_queryset()
|
||||||
existing_documents = queryset.filter(id__in=doc_ids)
|
existing_documents = queryset.filter(id__in=doc_ids)
|
||||||
@@ -1907,6 +1892,10 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
|
|||||||
document.delete()
|
document.delete()
|
||||||
raise Exception(f"El archivo no se encuentra en storage tras guardarlo: {file.name}")
|
raise Exception(f"El archivo no se encuentra en storage tras guardarlo: {file.name}")
|
||||||
document.archivo = ruta
|
document.archivo = ruta
|
||||||
|
# Ligar explícitamente la sub-entidad recién creada
|
||||||
|
# (exacto, sin depender del matching por nombre).
|
||||||
|
_campo_fk = {'partida': 'partida', 'cove': 'cove', 'edoc': 'edocument'}[tab_seccion]
|
||||||
|
setattr(document, _campo_fk, expediente_obj)
|
||||||
document.save()
|
document.save()
|
||||||
else:
|
else:
|
||||||
document.delete()
|
document.delete()
|
||||||
@@ -2008,14 +1997,7 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
|
|||||||
# la descarga no es destructiva, así que sí incluye archivos legacy.
|
# la descarga no es destructiva, así que sí incluye archivos legacy.
|
||||||
doc_ids = []
|
doc_ids = []
|
||||||
for partida in partidas:
|
for partida in partidas:
|
||||||
docs = Document.objects.filter(
|
doc_ids.extend(ids_documentos_entidad(partida, 'partida'))
|
||||||
pedimento_id=partida.pedimento_id,
|
|
||||||
archivo__iregex=patron_regex_partida(
|
|
||||||
partida.pedimento.pedimento_app, partida.numero_partida,
|
|
||||||
incluir_legacy=True,
|
|
||||||
),
|
|
||||||
).values_list('id', flat=True)
|
|
||||||
doc_ids.extend(docs)
|
|
||||||
|
|
||||||
queryset = self.get_queryset()
|
queryset = self.get_queryset()
|
||||||
docs_qs = queryset.filter(id__in=doc_ids)
|
docs_qs = queryset.filter(id__in=doc_ids)
|
||||||
@@ -2071,11 +2053,7 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
|
|||||||
|
|
||||||
doc_ids = []
|
doc_ids = []
|
||||||
for cove in coves:
|
for cove in coves:
|
||||||
docs = Document.objects.filter(
|
doc_ids.extend(ids_documentos_entidad(cove, 'cove'))
|
||||||
pedimento_id=cove.pedimento.id,
|
|
||||||
archivo__icontains=cove.numero_cove
|
|
||||||
).values_list('id', flat=True)
|
|
||||||
doc_ids.extend(docs)
|
|
||||||
|
|
||||||
queryset = self.get_queryset()
|
queryset = self.get_queryset()
|
||||||
docs_qs = queryset.filter(id__in=doc_ids)
|
docs_qs = queryset.filter(id__in=doc_ids)
|
||||||
@@ -2131,11 +2109,7 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
|
|||||||
|
|
||||||
doc_ids = []
|
doc_ids = []
|
||||||
for edoc in edocs:
|
for edoc in edocs:
|
||||||
docs = Document.objects.filter(
|
doc_ids.extend(ids_documentos_entidad(edoc, 'edocument'))
|
||||||
pedimento_id=edoc.pedimento.id,
|
|
||||||
archivo__icontains=edoc.numero_edocument
|
|
||||||
).values_list('id', flat=True)
|
|
||||||
doc_ids.extend(docs)
|
|
||||||
|
|
||||||
queryset = self.get_queryset()
|
queryset = self.get_queryset()
|
||||||
docs_qs = queryset.filter(id__in=doc_ids)
|
docs_qs = queryset.filter(id__in=doc_ids)
|
||||||
|
|||||||
115
core/document_links.py
Normal file
115
core/document_links.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""
|
||||||
|
Resolución de la sub-entidad (partida / cove / edocument) a la que pertenece un
|
||||||
|
Document, para poblar sus FK reales (`document.partida`/`cove`/`edocument`).
|
||||||
|
|
||||||
|
Fuente única, reusada por `Document.save()` (alta de filas nuevas) y por el comando
|
||||||
|
`backfill_document_links` (filas existentes). Generaliza la frontera de
|
||||||
|
[core.partida_docs] al resto de secciones.
|
||||||
|
|
||||||
|
Discriminador de sección: el `document_type_id` (lo asigna el microservicio al
|
||||||
|
generar el archivo). El nombre de archivo solo decide **cuál** registro dentro de
|
||||||
|
la sección, vía frontera tras `vu_<sec>_{pedimento_app}_{numero}` — NO se extrae la
|
||||||
|
llave partiendo por `_`, porque `pedimento_app` puede contener `_`
|
||||||
|
(p.ej. `vu_AC_0101_230_1703_3004804_4.pdf`): se itera la entidad con su número exacto.
|
||||||
|
|
||||||
|
Mapa tipo→sección (autoritativo, microservice/api/api_v2/modules/*/services.py):
|
||||||
|
Partida : 1 (respuesta), 17 (request), 18 (error)
|
||||||
|
Cove : 8 (respuesta), 7 (acuse), 19/20 (request/error), 23/24 (acuse request/error)
|
||||||
|
EDocument : 5 (respuesta), 4 (acuse), 21/22 (request/error), 25/26 (acuse request/error)
|
||||||
|
Nativos (sin FK): 2 (PC), 3 (remesa), 6, 13-16, y subidas sin tipo.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import posixpath
|
||||||
|
|
||||||
|
from core.partida_docs import es_doc_de_partida
|
||||||
|
|
||||||
|
PARTIDA_TYPES = {1, 17, 18}
|
||||||
|
COVE_TYPES = {7, 8, 19, 20, 23, 24}
|
||||||
|
EDOCUMENT_TYPES = {4, 5, 21, 22, 25, 26}
|
||||||
|
|
||||||
|
TYPE_TO_SECTION = {}
|
||||||
|
for _t in PARTIDA_TYPES:
|
||||||
|
TYPE_TO_SECTION[_t] = 'partida'
|
||||||
|
for _t in COVE_TYPES:
|
||||||
|
TYPE_TO_SECTION[_t] = 'cove'
|
||||||
|
for _t in EDOCUMENT_TYPES:
|
||||||
|
TYPE_TO_SECTION[_t] = 'edocument'
|
||||||
|
|
||||||
|
# Campo FK en Document y campo de llave de negocio en la entidad, por sección.
|
||||||
|
SECCION_CAMPO = {'partida': 'partida', 'cove': 'cove', 'edocument': 'edocument'}
|
||||||
|
SECCION_LLAVE = {'partida': 'numero_partida', 'cove': 'numero_cove', 'edocument': 'numero_edocument'}
|
||||||
|
# related_name del FK a Pedimento en cada entidad (EDocument usa 'documentos').
|
||||||
|
SECCION_RELACION = {'partida': 'partidas', 'cove': 'coves', 'edocument': 'documentos'}
|
||||||
|
# Prefijos de nombre por sección (cove y edoc tienen variante de acuse).
|
||||||
|
SECCION_PREFIJOS = {
|
||||||
|
'partida': ('vu_pt',),
|
||||||
|
'cove': ('vu_cove', 'vu_ac_cove'),
|
||||||
|
'edocument': ('vu_ed', 'vu_ac'),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def seccion_de_tipo(document_type_id):
|
||||||
|
"""Sección a la que pertenece un document_type_id, o None si es nativo."""
|
||||||
|
if document_type_id is None:
|
||||||
|
return None
|
||||||
|
return TYPE_TO_SECTION.get(int(document_type_id))
|
||||||
|
|
||||||
|
|
||||||
|
def _coincide_prefijo(base, prefijo):
|
||||||
|
"""True si `base` empieza con `prefijo` seguido de frontera (`_`, `.` o fin)."""
|
||||||
|
return base.startswith(prefijo) and (len(base) == len(prefijo) or base[len(prefijo)] in "_.")
|
||||||
|
|
||||||
|
|
||||||
|
def coincide(nombre_archivo, seccion, pedimento_app, numero):
|
||||||
|
"""Indica si `nombre_archivo` corresponde a (seccion, pedimento_app, numero)."""
|
||||||
|
if seccion == 'partida':
|
||||||
|
# Reusa el matcher de partidas (incluye formato legacy).
|
||||||
|
return es_doc_de_partida(nombre_archivo, pedimento_app, numero)
|
||||||
|
base = posixpath.basename(nombre_archivo or "").lower()
|
||||||
|
app = str(pedimento_app).strip().lower()
|
||||||
|
num = str(numero).strip().lower()
|
||||||
|
return any(
|
||||||
|
_coincide_prefijo(base, f"{pref}_{app}_{num}")
|
||||||
|
for pref in SECCION_PREFIJOS[seccion]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def match_entidad(nombre_archivo, seccion, pedimento_app, entidades):
|
||||||
|
"""Devuelve la entidad de `entidades` cuyo número coincide con el archivo, o None.
|
||||||
|
|
||||||
|
`entidades` es un iterable de instancias del modelo de la sección (Partida /
|
||||||
|
Cove / EDocument). Pensado para uso en memoria (backfill con listas precargadas).
|
||||||
|
"""
|
||||||
|
llave = SECCION_LLAVE[seccion]
|
||||||
|
for ent in entidades:
|
||||||
|
if coincide(nombre_archivo, seccion, pedimento_app, getattr(ent, llave)):
|
||||||
|
return ent
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolver_fk(document):
|
||||||
|
"""Resuelve la FK de sub-entidad de un Document → (campo, instancia | None).
|
||||||
|
|
||||||
|
`campo` es 'partida' | 'cove' | 'edocument' (el atributo a setear), o (None, None)
|
||||||
|
si el documento es nativo de pedimento o no tiene archivo. Hace las consultas
|
||||||
|
necesarias; para lotes usar `match_entidad` con entidades precargadas.
|
||||||
|
"""
|
||||||
|
seccion = seccion_de_tipo(getattr(document, 'document_type_id', None))
|
||||||
|
if not seccion or not document.archivo:
|
||||||
|
return (None, None)
|
||||||
|
pedimento = document.pedimento
|
||||||
|
entidades = getattr(pedimento, SECCION_RELACION[seccion]).all()
|
||||||
|
inst = match_entidad(document.archivo.name, seccion, pedimento.pedimento_app, entidades)
|
||||||
|
return (SECCION_CAMPO[seccion], inst)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Lectura / borrado / descarga: SIEMPRE por la FK real (no por nombre). El nombre
|
||||||
|
# solo se usa para ESTABLECER la FK (Document.save() en altas, backfill en filas
|
||||||
|
# viejas). Requiere que el backfill esté completo para datos previos.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def ids_documentos_entidad(entidad, seccion):
|
||||||
|
"""IDs de los documentos de la entidad (borrado/descarga) por la FK real."""
|
||||||
|
from api.record.models import Document # import diferido: evita ciclo con record.models
|
||||||
|
return list(Document.objects.filter(**{SECCION_CAMPO[seccion]: entidad}).values_list('id', flat=True))
|
||||||
Reference in New Issue
Block a user