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

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