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) <noreply@anthropic.com>
139 lines
6.1 KiB
Python
139 lines
6.1 KiB
Python
"""
|
|
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
|