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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user