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:
2026-06-24 08:13:47 -06:00
parent 244bbcb21c
commit 2e7d78fd8b
11 changed files with 592 additions and 196 deletions

37
CHANGELOG.md Normal file
View 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.

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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