from django.core.management.base import BaseCommand from django.db import transaction from api.customs.models import EDocument, Cove, EstadoDescarga from api.record.models import Document from api.utils.storage_service import storage_service class Command(BaseCommand): """ Reconciliación de estatus de descarga VUCEM (T2026-05-027). Detecta registros marcados como 'descargado' cuyo documento no existe en BD o cuyo archivo falta físicamente en storage (MinIO), y los transiciona a estado 'error' para que sean visibles y reprocesables. Sin --apply solo reporta (dry-run). Uso: python manage.py reconciliar_descargas # reporte python manage.py reconciliar_descargas --apply # corrige python manage.py reconciliar_descargas --organizacion """ help = "Reconcilia estatus de descarga de EDocs/COVEs contra documentos reales (BD + storage)" # Catálogo confirmado de document_type: # 4 = acuse EDoc, 7 = acuse COVE, 19/23 = request COVE, 21/25 = request EDoc, # 20 = error COVE, 22 = error EDoc, 24 = error acuse COVE, 26 = error acuse EDoc EXCLUIR_EDOC_GENERAL = [4, 21, 22, 25, 26] EXCLUIR_COVE_GENERAL = [7, 19, 20, 23, 24] def add_arguments(self, parser): parser.add_argument( '--apply', action='store_true', help='Aplica las correcciones; sin esta bandera solo reporta (dry-run)' ) parser.add_argument( '--organizacion', type=str, default=None, help='Limitar la reconciliación a una organización (UUID)' ) parser.add_argument( '--pedimento', type=str, default=None, help='Limitar la reconciliación a un pedimento (UUID)' ) def handle(self, *args, **opts): apply_changes = opts['apply'] detectados = [] flujos = [ # (modelo, campo_estado, campo_intentos, etiqueta, fn_documentos) (EDocument, 'acuse_estado', 'acuse_intentos', 'edoc.acuse', lambda r: Document.objects.filter( pedimento=r.pedimento, archivo__icontains=r.numero_edocument, document_type_id=4)), (EDocument, 'edocument_estado', 'edocument_intentos', 'edoc.general', lambda r: Document.objects.filter( pedimento=r.pedimento, archivo__icontains=r.numero_edocument, ).exclude(document_type_id__in=self.EXCLUIR_EDOC_GENERAL)), (Cove, 'acuse_cove_estado', 'acuse_cove_intentos', 'cove.acuse', lambda r: Document.objects.filter( pedimento=r.pedimento, archivo__icontains=r.numero_cove, document_type_id=7)), (Cove, 'cove_estado', 'cove_intentos', 'cove.general', lambda r: Document.objects.filter( pedimento=r.pedimento, archivo__icontains=r.numero_cove, ).exclude(document_type_id__in=self.EXCLUIR_COVE_GENERAL)), ] for modelo, campo_estado, campo_intentos, etiqueta, fn_documentos in flujos: qs = modelo.objects.filter(**{campo_estado: EstadoDescarga.DESCARGADO}) if opts['organizacion']: qs = qs.filter(organizacion_id=opts['organizacion']) if opts['pedimento']: qs = qs.filter(pedimento_id=opts['pedimento']) for registro in qs.select_related('pedimento').iterator(): numero = getattr(registro, 'numero_edocument', None) or registro.numero_cove docs = fn_documentos(registro) # Disponible = al menos un documento con fila en BD, tamaño > 0 # y archivo físicamente presente en storage disponible = any( doc.size and storage_service.file_exists(doc.archivo.name) for doc in docs ) if disponible: continue detectados.append((etiqueta, str(registro.id), numero, str(registro.pedimento_id))) if apply_changes: with transaction.atomic(): setattr(registro, campo_estado, EstadoDescarga.ERROR) registro.ultimo_error = ( f"Reconciliación: {etiqueta} marcado como descargado " f"sin archivo disponible en BD/storage" ) # save() del modelo sincroniza el booleano legado registro.save(update_fields=[campo_estado, 'ultimo_error']) modo = 'CORREGIDOS' if apply_changes else 'DETECTADOS (dry-run, usa --apply para corregir)' self.stdout.write(self.style.WARNING(f"{modo}: {len(detectados)}")) for etiqueta, registro_id, numero, pedimento_id in detectados: self.stdout.write(f" [{etiqueta}] id={registro_id} numero={numero} pedimento={pedimento_id}") if not detectados: self.stdout.write(self.style.SUCCESS("Sin inconsistencias: todos los 'descargado' tienen archivo disponible"))