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:
30
api/record/migrations/0004_document_subentidad_fk.py
Normal file
30
api/record/migrations/0004_document_subentidad_fk.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
55
api/record/migrations/0005_document_subentidad_idx.py
Normal file
55
api/record/migrations/0005_document_subentidad_idx.py
Normal 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'),
|
||||
)],
|
||||
),
|
||||
]
|
||||
22
api/record/migrations/0006_analyze_document.py
Normal file
22
api/record/migrations/0006_analyze_document.py
Normal 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,
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user