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

View File

@@ -238,8 +238,7 @@ from types import SimpleNamespace
from django.core.management import call_command
from django.test import TestCase, SimpleTestCase
from core.partida_docs import es_doc_de_partida, patron_regex_partida
from api.customs.serializers import PartidaSerializer
from api.customs.views import PartidaViewSet
from api.customs.serializers import PartidaSerializer, CoveSerializer, EDocumentSerializer
from api.customs.models import Partida
from api.record.models import Document, DocumentType
@@ -615,24 +614,16 @@ class PartidaDocumentosSerializerTests(TestCase):
data = PartidaSerializer(self.p1).data
self.assertEqual(len(data["documentos"]), 1)
def test_get_documentos_mismo_resultado_con_y_sin_prefetch(self):
self._doc(f"vu_PT_{self.APP}_1.xml")
def test_get_documentos_via_fk(self):
# save() liga la FK al crear; get_documentos lee por la FK real.
d1 = self._doc(f"vu_PT_{self.APP}_1.xml")
self._doc(f"vu_PT_{self.APP}_11.xml")
vs = PartidaViewSet()
mapa = vs._mapa_docs_partida([self.p1, self.p11])
ids_p1 = {d.id for d in mapa[(self.pedimento.id, 1)]}
ids_p11 = {d.id for d in mapa[(self.pedimento.id, 11)]}
self.assertEqual(len(ids_p1), 1)
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))
d1.refresh_from_db()
self.assertEqual(d1.partida_id, self.p1.id) # FK ligada en save()
data = PartidaSerializer(self.p1).data
self.assertEqual(len(data["documentos"]), 1)
self.assertIn(f"vu_PT_{self.APP}_1.xml", self._blob(data))
self.assertNotIn("_11", self._blob(data))
def test_patron_regex_partida_en_bd(self):
d1 = self._doc(f"vu_PT_{self.APP}_1")
@@ -648,14 +639,144 @@ class PartidaDocumentosSerializerTests(TestCase):
self.assertEqual(ids, {d1.id, d2.id, d3.id})
self.assertNotIn(d_otro.id, ids)
def test_mapa_docs_partida_es_una_sola_consulta(self):
# documentos para varias partidas del mismo pedimento
def test_prefetch_documentos_vu_evita_n_plus_1(self):
from django.db.models import Prefetch
self._doc(f"vu_PT_{self.APP}_1.xml")
self._doc(f"vu_PT_{self.APP}_11.xml")
partidas = list(
Partida.objects.filter(pedimento=self.pedimento).select_related("pedimento")
prefetch = Prefetch(
'documents',
queryset=Document.objects.filter(document_type_id=1).select_related('pedimento'),
to_attr='documentos_vu',
)
vs = PartidaViewSet()
# Una sola consulta sin importar cuántas partidas (evita el N+1).
with self.assertNumQueries(1):
vs._mapa_docs_partida(partidas)
partidas = list(
Partida.objects.filter(pedimento=self.pedimento)
.order_by('numero_partida').prefetch_related(prefetch)
)
# Serializar la lista no dispara consultas extra: todo viene del prefetch.
with self.assertNumQueries(0):
data = PartidaSerializer(partidas, many=True).data
por_num = {d['numero_partida']: d['documentos'] for d in data}
self.assertEqual(len(por_num[1]), 1)
self.assertEqual(len(por_num[11]), 1)
class DocumentLinksHelperTests(SimpleTestCase):
"""Resolver tipo→sección y matcher por frontera (core.document_links), sin BD."""
# pedimento_app con guiones bajos (caso real): no se puede extraer la llave
# partiendo por '_'; por eso se itera la entidad con su número exacto.
APP = "0101_230_1703_3004804"
def test_seccion_de_tipo(self):
from core.document_links import seccion_de_tipo
self.assertEqual(seccion_de_tipo(1), 'partida')
self.assertEqual(seccion_de_tipo(8), 'cove')
self.assertEqual(seccion_de_tipo(7), 'cove') # acuse cove
self.assertEqual(seccion_de_tipo(5), 'edocument')
self.assertEqual(seccion_de_tipo(4), 'edocument') # acuse edoc
self.assertIsNone(seccion_de_tipo(2)) # PC nativo
self.assertIsNone(seccion_de_tipo(3)) # remesa nativo
self.assertIsNone(seccion_de_tipo(None))
def test_coincide_cove(self):
from core.document_links import coincide
self.assertTrue(coincide(f"documents/vu_COVE_{self.APP}_654001.xml", 'cove', self.APP, "654001"))
self.assertTrue(coincide(f"documents/vu_AC_COVE_{self.APP}_654001.pdf", 'cove', self.APP, "654001"))
self.assertTrue(coincide(f"documents/vu_COVE_{self.APP}_654001_REQUEST.xml", 'cove', self.APP, "654001"))
# colisión de prefijo: 654001 no debe matchear 6540012
self.assertFalse(coincide(f"documents/vu_COVE_{self.APP}_6540012.xml", 'cove', self.APP, "654001"))
def test_coincide_edocument(self):
from core.document_links import coincide
self.assertTrue(coincide(f"documents/vu_ED_{self.APP}_EDOC001.pdf", 'edocument', self.APP, "EDOC001"))
self.assertTrue(coincide(f"documents/vu_AC_{self.APP}_EDOC001.pdf", 'edocument', self.APP, "EDOC001"))
self.assertFalse(coincide(f"documents/vu_ED_{self.APP}_EDOC0011.pdf", 'edocument', self.APP, "EDOC001"))
class DocumentFKResolutionTests(TestCase):
"""save()-resolución de FK por sección, lectura cove/edoc por FK y backfill."""
APP = "0101_230_1703_3004804" # con guiones bajos
def setUp(self):
from api.organization.models import Organizacion
from api.licence.models import Licencia
from .models import Pedimento, Cove, EDocument
self.licencia = Licencia.objects.create(nombre="LicFK", almacenamiento=100)
self.org = Organizacion.objects.create(
nombre="OrgFK", licencia=self.licencia, is_active=True, is_verified=True
)
self.pedimento = Pedimento.objects.create(
organizacion=self.org, pedimento="1234567", pedimento_app=self.APP,
aduana="034", patente="3420", numero_operacion="12345678",
)
self.partida = Partida.objects.create(
pedimento=self.pedimento, organizacion=self.org, numero_partida=3, descargado=True
)
self.cove = Cove.objects.create(
pedimento=self.pedimento, organizacion=self.org, numero_cove="654001"
)
self.edoc = EDocument.objects.create(
pedimento=self.pedimento, organizacion=self.org, numero_edocument="EDOC001"
)
for tid, nombre in [(1, "PT"), (8, "COVE"), (7, "AC_COVE"), (5, "ED"),
(4, "AC_ED"), (2, "PC"), (19, "COVE_REQ")]:
DocumentType.objects.get_or_create(id=tid, defaults={"nombre": nombre})
def _doc(self, filename, type_id):
return Document.objects.create(
organizacion=self.org, pedimento=self.pedimento, document_type_id=type_id,
archivo=f"documents/{filename}", size=100, extension="xml",
)
def test_save_liga_fk_por_seccion(self):
d_pt = self._doc(f"vu_PT_{self.APP}_3.xml", 1)
d_cv = self._doc(f"vu_COVE_{self.APP}_654001.xml", 8)
d_accv = self._doc(f"vu_AC_COVE_{self.APP}_654001.pdf", 7)
d_ed = self._doc(f"vu_ED_{self.APP}_EDOC001.pdf", 5)
d_aced = self._doc(f"vu_AC_{self.APP}_EDOC001.pdf", 4)
d_pc = self._doc(f"vu_PC_{self.APP}.xml", 2) # nativo: sin FK
for d in (d_pt, d_cv, d_accv, d_ed, d_aced, d_pc):
d.refresh_from_db()
self.assertEqual(d_pt.partida_id, self.partida.id)
self.assertEqual(d_cv.cove_id, self.cove.id)
self.assertEqual(d_accv.cove_id, self.cove.id) # acuse cove → cove padre
self.assertEqual(d_ed.edocument_id, self.edoc.id)
self.assertEqual(d_aced.edocument_id, self.edoc.id) # acuse edoc → edoc padre
self.assertIsNone(d_pc.partida_id)
self.assertIsNone(d_pc.cove_id)
self.assertIsNone(d_pc.edocument_id)
def test_lectura_cove_edoc_por_fk(self):
self._doc(f"vu_COVE_{self.APP}_654001.xml", 8)
self._doc(f"vu_AC_COVE_{self.APP}_654001.pdf", 7)
self._doc(f"vu_COVE_{self.APP}_654001_REQUEST.xml", 19) # request: excluido en lectura
self._doc(f"vu_ED_{self.APP}_EDOC001.pdf", 5)
cove_data = CoveSerializer(self.cove).data
edoc_data = EDocumentSerializer(self.edoc).data
self.assertEqual(len(cove_data["documentos"]), 2) # cove + acuse, sin request
self.assertEqual(len(edoc_data["documentos"]), 1)
def test_backfill_liga_filas_existentes(self):
d_pt = self._doc(f"vu_PT_{self.APP}_3.xml", 1)
d_cv = self._doc(f"vu_COVE_{self.APP}_654001.xml", 8)
# Simular filas viejas sin ligar (save() las ligó; las desligamos en BD).
Document.objects.filter(id__in=[d_pt.id, d_cv.id]).update(
partida=None, cove=None, edocument=None
)
# dry-run no escribe
call_command("backfill_document_links", pedimento=str(self.pedimento.id),
dry_run=True, stdout=StringIO())
d_pt.refresh_from_db()
self.assertIsNone(d_pt.partida_id)
# ejecución real liga
call_command("backfill_document_links", pedimento=str(self.pedimento.id), stdout=StringIO())
d_pt.refresh_from_db()
d_cv.refresh_from_db()
self.assertEqual(d_pt.partida_id, self.partida.id)
self.assertEqual(d_cv.cove_id, self.cove.id)
# idempotente: re-ejecutar no rompe ni cambia
call_command("backfill_document_links", pedimento=str(self.pedimento.id), stdout=StringIO())
d_pt.refresh_from_db()
self.assertEqual(d_pt.partida_id, self.partida.id)