""" 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