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

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