From b805c791dcc7185ac1c25d3a463da4083f07bf6f Mon Sep 17 00:00:00 2001 From: marcos Date: Wed, 24 Jun 2026 12:26:14 -0600 Subject: [PATCH] feat: comando dedup_documents para duplicados legados (T2025-09-004) Limpia documentos duplicados (misma sub-entidad + mismo document_type) creados ANTES del fix de reemplazo del microservicio (jun-2026). Conserva el mas reciente con archivo valido en storage, borra el resto (archivo MinIO si no lo referencia otra fila + fila + ajuste de cuota). --dry-run, conteo previo, idempotente; solo docs ligados a entidad (partida/cove/edocument). La creacion ya reemplaza desde jun-2026: verificado 0 duplicados posteriores al fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 6 + .../management/commands/dedup_documents.py | 138 ++++++++++++++++++ api/customs/tests.py | 49 +++++++ 3 files changed, 193 insertions(+) create mode 100644 api/customs/management/commands/dedup_documents.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c61c800..8d97447 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,12 @@ tipo, repos afectados, qué se hizo y por qué. Reglas del flujo en `../CLAUDE.m `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). + - Comando `dedup_documents` para limpiar documentos duplicados legados (misma entidad + + mismo tipo): conserva el más reciente con archivo válido en storage, borra el resto + (archivo MinIO si no lo referencia otra fila + fila + ajuste de cuota), `--dry-run`, + conteo previo, idempotente. Los duplicados eran **pre-fix**: la descarga ya reemplaza + en vez de re-crear desde jun-2026 (microservicio, `post_or_update_document`), verificado + con 0 duplicados creados después del fix. Solo aplica a docs ligados a entidad. - 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 diff --git a/api/customs/management/commands/dedup_documents.py b/api/customs/management/commands/dedup_documents.py new file mode 100644 index 0000000..dcbe421 --- /dev/null +++ b/api/customs/management/commands/dedup_documents.py @@ -0,0 +1,138 @@ +""" +Limpieza de documentos duplicados legados (T2025-09-004). + +Un mismo documento (misma sub-entidad + mismo document_type) quedó con varias +filas porque, antes del fix de reemplazo (microservicio, jun-2026), cada descarga +re-creaba en vez de reemplazar. La creación ya está corregida; esto solo limpia lo +viejo. + +Estrategia: encuentra los grupos duplicados con UNA agregación global por FK +(no itera pedimento por pedimento — hay ~110k pedimentos y casi ninguno tiene +duplicados). Por cada grupo (FK partida/cove/edocument + document_type, con >1 +fila): conserva el MÁS RECIENTE cuyo archivo exista en storage (si ninguno existe, +conserva el más reciente y NO borra el grupo entero) y elimina el resto —archivo +en MinIO (si no lo referencia otra fila) + fila + ajuste de cuota vía Document.delete(). + +Solo toca documentos ligados a una entidad (partida/cove/edoc). NO toca documentos +nativos del pedimento ni subidas sin FK. + +Uso: + python manage.py dedup_documents [--pedimento UUID] [--organizacion UUID] + [--offset N] [--limit N] [--dry-run] +""" + +from django.core.management.base import BaseCommand +from django.db import transaction +from django.db.models import Count + +from api.record.models import Document +from api.utils.storage_service import storage_service + +# Campos FK de sub-entidad sobre los que se detectan duplicados. +_CAMPOS_FK = ('partida_id', 'cove_id', 'edocument_id') + + +class Command(BaseCommand): + help = "Elimina documentos duplicados legados (misma entidad + mismo tipo), conservando el más reciente con archivo válido." + + def add_arguments(self, parser): + parser.add_argument('--pedimento', type=str, default=None) + parser.add_argument('--organizacion', type=str, default=None) + parser.add_argument('--offset', type=int, default=0, help='Saltar los primeros N grupos') + parser.add_argument('--limit', type=int, default=None, help='Procesar máximo N grupos') + parser.add_argument('--dry-run', action='store_true', help='Reporta sin borrar') + + def handle(self, *args, **opts): + dry_run = opts['dry_run'] + base = Document.objects.all() + if opts['pedimento']: + base = base.filter(pedimento_id=opts['pedimento']) + if opts['organizacion']: + base = base.filter(pedimento__organizacion_id=opts['organizacion']) + + offset = opts['offset'] or 0 + limit = opts['limit'] + if dry_run: + self.stdout.write(self.style.WARNING("== DRY-RUN: no se borra nada ==")) + + stats = {'grupos': 0, 'eliminados': 0, 'bytes': 0, 'sin_archivo_valido': 0} + visto = 0 # índice global de grupos (para offset/limit) + for campo in _CAMPOS_FK: + # Una sola agregación: todos los grupos duplicados de esta FK. + grupos = ( + base.filter(**{campo + '__isnull': False}) + .values('pedimento_id', campo, 'document_type_id') + .annotate(n=Count('id')) + .filter(n__gt=1) + .order_by() # limpia el ordering por defecto del modelo (rompe el GROUP BY) + ) + for g in grupos.iterator(): + if visto < offset: + visto += 1 + continue + if limit is not None and stats['grupos'] >= limit: + break + visto += 1 + self._dedup_grupo(campo, g, dry_run, stats) + if limit is not None and stats['grupos'] >= limit: + break + + mb = stats['bytes'] / (1024 * 1024) + self.stdout.write("") + self.stdout.write(self.style.SUCCESS( + "Grupos con duplicados: %d | filas eliminadas: %d | espacio liberado: %.1f MB | grupos sin archivo válido (se conservó el más reciente): %d" + % (stats['grupos'], stats['eliminados'], mb, stats['sin_archivo_valido']) + )) + + def _dedup_grupo(self, campo, g, dry_run, stats): + docs = list( + Document.objects.filter( + pedimento_id=g['pedimento_id'], + document_type_id=g['document_type_id'], + **{campo: g[campo]}, + ).select_related('pedimento').order_by('-created_at') + ) + if len(docs) < 2: + return + conservado = self._elegir_conservado(docs, dry_run, stats) + a_borrar = [d for d in docs if d.id != conservado.id] + if not a_borrar: + return + stats['grupos'] += 1 + # SELECT + COUNT previo (estándar de la organización): reportar antes de borrar. + self.stdout.write( + " ped=%s %s=%s type=%s: conservar %s, eliminar %d (%s)" + % (str(g['pedimento_id'])[:8], campo, g[campo], g['document_type_id'], + str(conservado.id)[:8], len(a_borrar), ', '.join(str(d.id)[:8] for d in a_borrar)) + ) + if not dry_run: + for d in a_borrar: + self._borrar(d, stats) + + def _elegir_conservado(self, docs_desc, dry_run, stats): + """docs_desc viene ordenado por -created_at. En dry-run conserva el más + reciente sin tocar storage; en ejecución real, el más reciente cuyo + archivo exista en MinIO (fallback: el más reciente, para no borrar todo).""" + if dry_run: + return docs_desc[0] + for d in docs_desc: + try: + if d.archivo and storage_service.file_exists(d.archivo.name): + return d + except Exception: + continue + stats['sin_archivo_valido'] += 1 + return docs_desc[0] + + def _borrar(self, doc, stats): + with transaction.atomic(): + # Borrar el archivo en MinIO solo si ninguna OTRA fila lo referencia. + nombre = doc.archivo.name if doc.archivo else None + if nombre and not Document.objects.filter(archivo=nombre).exclude(id=doc.id).exists(): + try: + storage_service.delete_file(nombre) + except Exception as e: + self.stdout.write(self.style.WARNING(f" no se pudo borrar de storage {nombre}: {e}")) + stats['bytes'] += doc.size or 0 + doc.delete() # ajusta la cuota de almacenamiento (UsoAlmacenamiento) + stats['eliminados'] += 1 diff --git a/api/customs/tests.py b/api/customs/tests.py index 17974ce..5c9ebb9 100644 --- a/api/customs/tests.py +++ b/api/customs/tests.py @@ -780,3 +780,52 @@ class DocumentFKResolutionTests(TestCase): 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) + + def _tres_copias_edoc(self): + """3 copias del mismo edoc (type 5) con created_at d1