diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c61c800 --- /dev/null +++ b/CHANGELOG.md @@ -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 "";` 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. diff --git a/api/customs/management/commands/backfill_document_links.py b/api/customs/management/commands/backfill_document_links.py new file mode 100644 index 0000000..ba84ad7 --- /dev/null +++ b/api/customs/management/commands/backfill_document_links.py @@ -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 + ) diff --git a/api/customs/serializers.py b/api/customs/serializers.py index a75a131..8ce2650 100644 --- a/api/customs/serializers.py +++ b/api/customs/serializers.py @@ -11,10 +11,8 @@ from api.customs.models import ( ) from django.db import models 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.vucem.serializers import VucemSerializer -from core.partida_docs import es_doc_de_partida import logging logger = logging.getLogger(__name__) @@ -52,32 +50,16 @@ class PartidaSerializer(serializers.ModelSerializer): documentos = serializers.SerializerMethodField() 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 [] - try: - # El matching documento→partida se hace por nombre de archivo con - # frontera real (core.partida_docs); document_type_id=1 son los - # documentos de respuesta de partida (excluye REQUEST/ERROR 17/18). - mapa = self.context.get('docs_por_partida') - if mapa is not None: - # Camino optimizado: la vista precargó el mapa de la página. - 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] - + # Documentos de respuesta de la partida (tipo 1) vía la FK real + # document.partida. 'documentos_vu' lo precarga el ViewSet con prefetch; + # si no está, se consulta directo (retrieve u otros callers). + docs = getattr(obj, 'documentos_vu', None) + if docs is None: + docs = list(obj.documents.filter(document_type_id=1).select_related('pedimento', 'fuente')) return DocumentSerializer(docs, many=True, context=self.context).data - except Exception as e: logger.warning("get_documentos partida %s: %s", getattr(obj, 'id', '?'), e) return [] @@ -170,43 +152,18 @@ class EDocumentSerializer(serializers.ModelSerializer): documentos = serializers.SerializerMethodField() def get_documentos(self, obj): - """ - Busca documentos en la tabla `document` que coincidan con el - `numero_edocument` dentro del nombre del archivo (`archivo`). Se - filtra por organización para evitar devolver documentos de otras orgs. - 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): + """Documentos del e-documento (incluye acuse y errores; excluye solo los + REQUEST 21/25) vía la FK real document.edocument. 'documentos_vu' lo + precarga el ViewSet con prefetch; si no está, se consulta directo.""" + if not obj: return [] - - if not obj or not getattr(obj, 'pedimento', None): - return [] - - # if not obj or not getattr(obj, 'pedimento_id', None): - # return [] - try: - numero = str(obj.numero_edocument).strip() - # id_pedimento = str(obj.pedimento_id).strip() - - # excluir solo request (21, 25); errores (22, 26) se incluyen para detección en frontend - qs = Document.objects.filter( - pedimento=obj.pedimento, - 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 + docs = getattr(obj, 'documentos_vu', None) + if docs is None: + docs = list(obj.documents.exclude(document_type_id__in=[21, 25]).select_related('pedimento', 'fuente')) + return DocumentSerializer(docs, many=True, context=self.context).data + except Exception as e: + logger.warning("get_documentos edocument %s: %s", getattr(obj, 'id', '?'), e) return [] class Meta: @@ -262,39 +219,18 @@ class CoveSerializer(serializers.ModelSerializer): return attrs def get_documentos(self, obj): - """ - Busca documentos en la tabla `document` que coincidan con el - `numero_cove` dentro del nombre del archivo (`archivo`). Se - filtra por organización para evitar devolver documentos de otras orgs. - 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): + """Documentos del cove (incluye acuse cove y errores; excluye solo los + REQUEST 19/23) vía la FK real document.cove. 'documentos_vu' lo precarga + el ViewSet con prefetch; si no está, se consulta directo.""" + if not obj: return [] - - if not obj or not getattr(obj, 'pedimento', None): - return [] - try: - numero = str(obj.numero_cove).strip() - - # Excluir solo request (19, 23); errores (20, 24) se incluyen para detección en frontend - qs = Document.objects.filter( - pedimento=obj.pedimento, - archivo__icontains=numero, - ).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 + docs = getattr(obj, 'documentos_vu', None) + if docs is None: + docs = list(obj.documents.exclude(document_type_id__in=[19, 23]).select_related('pedimento', 'fuente')) + return DocumentSerializer(docs, many=True, context=self.context).data + except Exception as e: + logger.warning("get_documentos cove %s: %s", getattr(obj, 'id', '?'), e) return [] class ImportadorSerializer(serializers.ModelSerializer): diff --git a/api/customs/tests.py b/api/customs/tests.py index 37ba153..17974ce 100644 --- a/api/customs/tests.py +++ b/api/customs/tests.py @@ -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) diff --git a/api/customs/views.py b/api/customs/views.py index 32afaaa..e3ea3f3 100644 --- a/api/customs/views.py +++ b/api/customs/views.py @@ -60,8 +60,7 @@ from django.core.files.base import ContentFile from django.db import transaction from rest_framework.parsers import MultiPartParser, FormParser from api.record.models import Document, DocumentType, Fuente -from core.partida_docs import es_doc_de_partida -from collections import defaultdict +from django.db.models import Prefetch from unicodedata import normalize from datetime import datetime from django.utils import timezone @@ -2334,14 +2333,21 @@ class PartidaViewSet(viewsets.ModelViewSet): def get_queryset(self): 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): - return Partida.objects.all() + return Partida.objects.all().prefetch_related(prefetch_docs) if not user_has_permission(user, 'partidas.view'): return Partida.objects.none() org = get_org_context(user) if not org: 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 # operativos ven todo lo de su org; is_importador no los degrada. if ( @@ -2356,41 +2362,6 @@ class PartidaViewSet(viewsets.ModelViewSet): return qs.filter(pedimento__contribuyente__in=user.rfc.all()) 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): if is_internal_service_request(self.request): serializer.save() @@ -2633,7 +2604,14 @@ class ViewSetEDocument(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada def get_queryset(self): if not user_has_permission(self.request.user, 'edocuments.view'): 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): if is_internal_service_request(self.request): @@ -2801,7 +2779,14 @@ class ViewSetCove(viewsets.ModelViewSet, OrganizacionFiltradaMixin): def get_queryset(self): if not user_has_permission(self.request.user, 'coves.view'): 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): if is_internal_service_request(self.request): diff --git a/api/record/migrations/0004_document_subentidad_fk.py b/api/record/migrations/0004_document_subentidad_fk.py new file mode 100644 index 0000000..6c92f0c --- /dev/null +++ b/api/record/migrations/0004_document_subentidad_fk.py @@ -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'), + ), + ] diff --git a/api/record/migrations/0005_document_subentidad_idx.py b/api/record/migrations/0005_document_subentidad_idx.py new file mode 100644 index 0000000..933c720 --- /dev/null +++ b/api/record/migrations/0005_document_subentidad_idx.py @@ -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 ""; 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'), + )], + ), + ] diff --git a/api/record/migrations/0006_analyze_document.py b/api/record/migrations/0006_analyze_document.py new file mode 100644 index 0000000..c7ec33e --- /dev/null +++ b/api/record/migrations/0006_analyze_document.py @@ -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, + ), + ] diff --git a/api/record/models.py b/api/record/models.py index 0667460..3edb5bd 100644 --- a/api/record/models.py +++ b/api/record/models.py @@ -17,6 +17,14 @@ class Document(models.Model): fuente = models.ForeignKey('Fuente', on_delete=models.CASCADE, related_name='documents', blank=True, null=True) 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) updated_at = models.DateTimeField(auto_now=True) @@ -30,6 +38,17 @@ class Document(models.Model): else: 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 uso_almacenamiento, created = UsoAlmacenamiento.objects.get_or_create( organizacion=self.organizacion, @@ -77,6 +96,14 @@ class Document(models.Model): verbose_name_plural = "Documents" db_table = 'document' 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): nombre = models.CharField(max_length=100, unique=True) diff --git a/api/record/views.py b/api/record/views.py index ea31c84..73268f3 100644 --- a/api/record/views.py +++ b/api/record/views.py @@ -33,7 +33,7 @@ from core.permissions import ( user_has_permission, IsInternalService, ) -from core.partida_docs import patron_regex_partida +from core.document_links import ids_documentos_entidad import logging 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. doc_ids = [] for partida in partidas: - docs = Document.objects.filter( - 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) + doc_ids.extend(ids_documentos_entidad(partida, 'partida')) queryset = self.get_queryset() 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 doc_ids = [] for cove in coves: - docs = Document.objects.filter( - pedimento_id=cove.pedimento.id, - archivo__icontains=cove.numero_cove - ).values_list('id', flat=True) - doc_ids.extend(docs) + doc_ids.extend(ids_documentos_entidad(cove, 'cove')) queryset = self.get_queryset() 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 doc_ids = [] for edoc in edocs: - docs = Document.objects.filter( - pedimento_id=edoc.pedimento.id, - archivo__icontains=edoc.numero_edocument - ).values_list('id', flat=True) - doc_ids.extend(docs) + doc_ids.extend(ids_documentos_entidad(edoc, 'edocument')) queryset = self.get_queryset() existing_documents = queryset.filter(id__in=doc_ids) @@ -1907,6 +1892,10 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin): document.delete() raise Exception(f"El archivo no se encuentra en storage tras guardarlo: {file.name}") 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() else: document.delete() @@ -2008,14 +1997,7 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin): # la descarga no es destructiva, así que sí incluye archivos legacy. doc_ids = [] for partida in partidas: - docs = Document.objects.filter( - 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) + doc_ids.extend(ids_documentos_entidad(partida, 'partida')) queryset = self.get_queryset() docs_qs = queryset.filter(id__in=doc_ids) @@ -2071,11 +2053,7 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin): doc_ids = [] for cove in coves: - docs = Document.objects.filter( - pedimento_id=cove.pedimento.id, - archivo__icontains=cove.numero_cove - ).values_list('id', flat=True) - doc_ids.extend(docs) + doc_ids.extend(ids_documentos_entidad(cove, 'cove')) queryset = self.get_queryset() docs_qs = queryset.filter(id__in=doc_ids) @@ -2131,11 +2109,7 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin): doc_ids = [] for edoc in edocs: - docs = Document.objects.filter( - pedimento_id=edoc.pedimento.id, - archivo__icontains=edoc.numero_edocument - ).values_list('id', flat=True) - doc_ids.extend(docs) + doc_ids.extend(ids_documentos_entidad(edoc, 'edocument')) queryset = self.get_queryset() docs_qs = queryset.filter(id__in=doc_ids) diff --git a/core/document_links.py b/core/document_links.py new file mode 100644 index 0000000..7759690 --- /dev/null +++ b/core/document_links.py @@ -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__{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))