""" Diagnóstico y corrección de partidas con descargado=True que NO tienen un XML de respuesta de partida válido. Una partida cuenta como realmente descargada solo si alguno de sus documentos contiene el nodo sin true. Clasificación por contenido de cada documento candidato (excluye types 17/18, que ya están identificados como REQUEST/ERROR): - valida : consultarPartidaRespuesta sin tieneError=true - error : tieneError=true → renombra a _ERROR, type 18 - request : consultarPartidaPeticion → renombra a _REQUEST, type 17 (eco de la petición guardado como si fuera respuesta) - desconocido : contenido no identificable → solo reporte - ausente : registro en BD cuyo archivo no existe en storage - no_verificable : storage inaccesible (excepción al consultar/leer) Veredicto por partida con descargado=True: - ≥1 valida → conserva descargado=True - 0 validas y ≥1 no_verificable → sin cambios (storage inaccesible) - 0 validas, ≥1 ausente y NINGÚN archivo del pedimento existe en storage → sin cambios (canario: probablemente se está corriendo contra un storage que no es el de esta BD, p. ej. dev) - en cualquier otro caso → descargado=False (incluye partidas que solo tienen el REQUEST, ningún doc, o registros fantasma con el storage real) Canario de storage: si al menos un archivo vu_PT_ del pedimento (REQUEST, ERROR o respuesta) sí existe en storage, el storage es el correcto y los documentos ausentes son registros fantasma reales (BD sin archivo). Convenciones de nomenclatura del microservicio: - REQUEST (type 17): vu_PT_{pedimento_app}_{partida}_REQUEST.xml - ERROR (type 18): vu_PT_{pedimento_app}_{partida}_ERROR.xml - Éxito (type 1): vu_PT_{pedimento_app}_{partida}.xml (el storage puede agregar sufijos de unicidad: vu_PT_{...}_{partida}_Ab12xQ.xml) - Legacy : vu_PT_..._{partida}.xml (número de partida al final) Uso: python manage.py fix_partidas_error --pedimento --dry-run python manage.py fix_partidas_error --organizacion --dry-run python manage.py fix_partidas_error --organizacion python manage.py fix_partidas_error --solo-malformados --dry-run python manage.py fix_partidas_error --dry-run # todas las orgs """ import io import posixpath import re from django.core.management.base import BaseCommand, CommandError from django.db import transaction from django.db.models import Q from django.db.models.functions import Length from api.customs.models import Partida, Pedimento from api.record.models import Document from api.utils.minio_client import minio_client _PT_REQUEST = 17 _PT_ERROR = 18 # Clasificaciones por contenido del XML _VALIDA = "valida" _ERROR_VU = "error" _REQUEST_ECO = "request" _DESCONOCIDO = "desconocido" _AUSENTE = "ausente" _NO_VERIFICABLE = "no_verificable" # clase → (sufijo de archivo, document_type destino) _RECLASIFICACION = { _ERROR_VU: ("ERROR", _PT_ERROR), _REQUEST_ECO: ("REQUEST", _PT_REQUEST), } class Command(BaseCommand): help = "Corrige partidas descargado=True sin XML de respuesta de partida válido." def add_arguments(self, parser): parser.add_argument( "--organizacion", metavar="UUID", help="UUID de la organización. Sin este arg: todas las orgs.", ) parser.add_argument( "--pedimento", metavar="UUID", help="UUID del pedimento a diagnosticar/corregir.", ) # Filtros de fecha (aplican sobre fecha_pago del pedimento) parser.add_argument( "--fecha-desde", metavar="YYYY-MM-DD", help="Procesar pedimentos con fecha_pago >= esta fecha.", ) parser.add_argument( "--fecha-hasta", metavar="YYYY-MM-DD", help="Procesar pedimentos con fecha_pago <= esta fecha.", ) parser.add_argument( "--solo-malformados", action="store_true", help="Limitar a pedimentos con aduana/patente/pedimento/numero_operacion inválidos (comportamiento anterior).", ) # Control de lote parser.add_argument( "--offset", type=int, default=0, help="Saltar los primeros N pedimentos (default: 0).", ) parser.add_argument( "--limit", type=int, default=0, help="Procesar máximo N pedimentos (default: 0 = todos).", ) parser.add_argument( "--dry-run", action="store_true", help="Solo diagnóstico, sin aplicar cambios.", ) # ------------------------------------------------------------------ # # Entry point # ------------------------------------------------------------------ # def handle(self, *args, **options): org_id = options.get("organizacion") ped_id = options.get("pedimento") fecha_desde = options.get("fecha_desde") fecha_hasta = options.get("fecha_hasta") offset = options["offset"] limit = options["limit"] dry_run = options["dry_run"] if dry_run: self.stdout.write(self.style.WARNING( "=== MODO PRUEBA (--dry-run): Sin cambios en BD ni storage ===\n" )) if ped_id: self._handle_single(ped_id, dry_run) return # Universo: pedimentos con al menos una partida descargado=True ped_ids = Partida.objects.filter(descargado=True).values_list( "pedimento_id", flat=True ).distinct() base_qs = self._malformed_qs() if options["solo_malformados"] else Pedimento.objects.all() ped_qs = base_qs.filter(id__in=ped_ids) if org_id: ped_qs = ped_qs.filter(organizacion_id=org_id) if fecha_desde: ped_qs = ped_qs.filter(fecha_pago__gte=fecha_desde) if fecha_hasta: ped_qs = ped_qs.filter(fecha_pago__lte=fecha_hasta) ped_qs = ped_qs.select_related("organizacion").order_by("fecha_pago", "pedimento_app") total_sin_filtro = ped_qs.count() if offset: ped_qs = ped_qs[offset:] if limit: ped_qs = ped_qs[:limit] total = total_sin_filtro if not (offset or limit) else min( limit or total_sin_filtro, max(0, total_sin_filtro - offset) ) self.stdout.write( f"Pedimentos con partidas descargadas (total): {total_sin_filtro}\n" f"Procesando este lote : {total}" + (f" [offset={offset}]" if offset else "") + (f" [limit={limit}]" if limit else "") + (f" [solo malformados]" if options["solo_malformados"] else "") + "\n" ) if total == 0: self.stdout.write(self.style.SUCCESS("Nada que revisar en este lote.")) return stats = self._stats_vacios() n_peds = 0 for ped in ped_qs: parciales = self._process_pedimento(ped, dry_run) n_peds += 1 for k in stats: stats[k] += parciales[k] self._print_summary(n_peds, stats, dry_run) # ------------------------------------------------------------------ # # Flujo --pedimento # ------------------------------------------------------------------ # def _handle_single(self, ped_id, dry_run): try: ped = Pedimento.objects.get(id=ped_id) except Pedimento.DoesNotExist: raise CommandError(f"Pedimento {ped_id!r} no encontrado.") # Diagnóstico de campos: informativo, ya no excluye pedimentos válidos self._print_ped_diagnosis(ped, self._field_checks(ped)) stats = self._process_pedimento(ped, dry_run) self._print_summary(1, stats, dry_run) # ------------------------------------------------------------------ # # Queryset de pedimentos malformados # ------------------------------------------------------------------ # def _malformed_qs(self): return Pedimento.objects.annotate( aduana_len=Length("aduana"), patente_len=Length("patente"), pedimento_len=Length("pedimento"), ).filter( Q(aduana__isnull=True) | Q(aduana="") | Q(aduana_len__lt=3) | Q(numero_operacion__isnull=True) | Q(numero_operacion="") | Q(patente__isnull=True) | Q(patente="") | Q(patente_len__lt=4) | Q(pedimento__isnull=True) | Q(pedimento="") | Q(pedimento_len__lt=7) ) # ------------------------------------------------------------------ # # Diagnóstico de un pedimento # ------------------------------------------------------------------ # def _field_checks(self, ped): return { "aduana (debe tener 3 dígitos)": not ped.aduana or len(ped.aduana.strip()) < 3, "numero_operacion (obligatorio)": not ped.numero_operacion or not ped.numero_operacion.strip(), "patente (debe tener 4 dígitos)": not ped.patente or len(ped.patente.strip()) < 4, "pedimento_fld (debe tener 7 dígitos)": not ped.pedimento or len(ped.pedimento.strip()) < 7, } def _print_ped_diagnosis(self, ped, checks): es_malo = any(checks.values()) estado = self.style.ERROR("MALFORMADO") if es_malo else self.style.SUCCESS("VÁLIDO") self.stdout.write( f"Pedimento {ped.pedimento_app} (id={ped.id}) → {estado}\n" f" aduana = {ped.aduana!r} (len={len(ped.aduana or '')})\n" f" patente = {ped.patente!r} (len={len(ped.patente or '')})\n" f" numero_op = {ped.numero_operacion!r}\n" f" pedimento_fld = {ped.pedimento!r} (len={len(ped.pedimento or '')})\n" ) for campo, malo in checks.items(): marca = self.style.ERROR("✗") if malo else self.style.SUCCESS("✓") self.stdout.write(f" {marca} {campo}") self.stdout.write("") # ------------------------------------------------------------------ # # Procesamiento de un pedimento # ------------------------------------------------------------------ # def _stats_vacios(self): return { "partidas": 0, # partidas descargado=True revisadas "corregidas": 0, # partidas marcadas descargado=False "bloqueadas": 0, # partidas sin cambios (storage inaccesible/equivocado) "docs_error": 0, # docs renombrados a _ERROR (type 18) "docs_request": 0, # docs reclasificados a _REQUEST (type 17) "desconocidos": 0, # docs con contenido no identificable "fantasmas": 0, # registros en BD sin archivo en storage (no se borran) } def _process_pedimento(self, ped, dry_run): es_malformado = any(self._field_checks(ped).values()) self.stdout.write( f"Pedimento: {ped.pedimento_app} | " f"aduana={ped.aduana!r} patente={ped.patente!r} num_op={ped.numero_operacion!r}" + (" [MALFORMADO]" if es_malformado else "") ) stats = self._stats_vacios() partidas = Partida.objects.filter(pedimento=ped, descargado=True) n_partidas = partidas.count() if n_partidas == 0: self.stdout.write(" → Sin partidas con descargado=True\n") return stats self.stdout.write(f" Partidas con descargado=True: {n_partidas}") # Una sola consulta por pedimento; la asignación por partida es en memoria docs_pedimento = list( Document.objects.filter(pedimento=ped, archivo__icontains="vu_PT_") ) # Canario perezoso: ¿existe en storage al menos un archivo del pedimento? # Distingue "registro fantasma con storage real" de "storage equivocado". canario = {"valor": None} def storage_es_correcto(): if canario["valor"] is None: canario["valor"] = self._storage_tiene_archivos(docs_pedimento) return canario["valor"] for partida in partidas: self._process_partida(ped, partida, docs_pedimento, storage_es_correcto, dry_run, stats) self.stdout.write("") return stats def _storage_tiene_archivos(self, docs): """True si al menos un archivo vu_PT_ del pedimento existe en storage.""" for doc in docs: try: if minio_client.file_exists(doc.archivo.name): return True except Exception: return False # storage inaccesible: modo conservador return False # ------------------------------------------------------------------ # # Procesamiento de una partida # ------------------------------------------------------------------ # def _process_partida(self, ped, partida, docs_pedimento, storage_es_correcto, dry_run, stats): stats["partidas"] += 1 docs = self._docs_de_partida(docs_pedimento, ped.pedimento_app, partida.numero_partida) candidatos = [d for d in docs if d.document_type_id not in (_PT_REQUEST, _PT_ERROR)] n_requests = sum(1 for d in docs if d.document_type_id == _PT_REQUEST) n_errores = sum(1 for d in docs if d.document_type_id == _PT_ERROR) self.stdout.write( f"\n Partida {partida.numero_partida}: {len(candidatos)} doc(s) de respuesta a revisar" f" (REQUEST: {n_requests}, ERROR previos: {n_errores})" ) clasificados = [] for doc in candidatos: clase, motivo = self._classify_document(doc) iconos = { _VALIDA: self.style.SUCCESS("✓ partida válida"), _ERROR_VU: self.style.ERROR("✗ ERROR VUCEM"), _REQUEST_ECO: self.style.WARNING("↺ es REQUEST, no respuesta"), _DESCONOCIDO: self.style.WARNING("? contenido desconocido"), _AUSENTE: self.style.WARNING("✗ registro sin archivo en storage"), _NO_VERIFICABLE: self.style.WARNING("⚠ storage inaccesible"), } self.stdout.write(f" [{iconos[clase]}] type={doc.document_type_id} | {doc.archivo.name}") if motivo: self.stdout.write(f" motivo: {motivo}") clasificados.append((doc, clase)) validas = [d for d, c in clasificados if c == _VALIDA] no_verificables = [d for d, c in clasificados if c == _NO_VERIFICABLE] ausentes = [d for d, c in clasificados if c == _AUSENTE] corregibles = [(d, c) for d, c in clasificados if c in _RECLASIFICACION] stats["desconocidos"] += sum(1 for _, c in clasificados if c == _DESCONOCIDO) # Veredicto: solo una consultarPartidaRespuesta sin error mantiene la # partida como descargada. Storage inaccesible bloquea el cambio; un # archivo ausente solo bloquea cuando NINGÚN archivo del pedimento # existe en storage (canario: posible storage equivocado, p. ej. dev). if validas: marcar_no_descargada = False veredicto = self.style.SUCCESS("OK: tiene respuesta de partida válida") elif no_verificables: marcar_no_descargada = False stats["bloqueadas"] += 1 veredicto = self.style.WARNING( "SIN CAMBIOS: storage inaccesible — ejecutar donde el storage sea accesible" ) elif ausentes and not storage_es_correcto(): marcar_no_descargada = False stats["bloqueadas"] += 1 veredicto = self.style.WARNING( "SIN CAMBIOS: ningún archivo del pedimento existe en storage — " "¿se está corriendo contra el storage correcto?" ) else: marcar_no_descargada = True stats["corregidas"] += 1 stats["fantasmas"] += len(ausentes) veredicto = self.style.ERROR("descargado → False (sin XML de partida válido)") self.stdout.write(f" Veredicto: {veredicto}") for _, clase in corregibles: clave = "docs_error" if clase == _ERROR_VU else "docs_request" stats[clave] += 1 if not dry_run and (corregibles or marcar_no_descargada): self._apply_fix(partida, corregibles, marcar_no_descargada, ped.pedimento_app) # ------------------------------------------------------------------ # # Asignación de documentos a una partida por nombre de archivo # ------------------------------------------------------------------ # def _docs_de_partida(self, docs, pedimento_app, numero_partida): """ Naming actual : vu_PT_{pedimento_app}_{numero} seguido de "_" o "." (cubre éxito canónico, sufijos de unicidad del storage, REQUEST y ERROR; "_" evita confundir partida 1 con 11) Naming legacy : vu_PT_..._{numero}.xml (número de partida al final) """ prefijo = f"vu_pt_{pedimento_app}_{numero_partida}".lower() legacy_re = re.compile( rf"^vu_pt_.+_{re.escape(str(numero_partida))}\.xml$", re.IGNORECASE ) asignados = {} for doc in docs: base = posixpath.basename(doc.archivo.name or "").lower() es_actual = ( base.startswith(prefijo) and len(base) > len(prefijo) and base[len(prefijo)] in "_." ) if es_actual or legacy_re.match(base): asignados[doc.id] = doc return list(asignados.values()) # ------------------------------------------------------------------ # # Clasificación del contenido XML # ------------------------------------------------------------------ # def _classify_document(self, doc): """ Lee el XML desde MinIO y clasifica su contenido. Retorna (clase, motivo: str | None). """ name = doc.archivo.name try: if not minio_client.file_exists(name): return _AUSENTE, "archivo no encontrado en storage" response = minio_client._client.get_object(minio_client._bucket_name, name) try: content = response.read() finally: response.close() response.release_conn() text = content.decode("utf-8", errors="replace").lower() except Exception as e: return _NO_VERIFICABLE, f"excepción al leer archivo: {e}" if "tieneerror>true<" in text: return _ERROR_VU, "tieneError=true detectado en XML" if "consultarpartidarespuesta" in text: return _VALIDA, None if "consultarpartidapeticion" in text: return _REQUEST_ECO, "es la petición SOAP, no la respuesta" return _DESCONOCIDO, "sin consultarPartidaRespuesta, sin consultarPartidaPeticion y sin tieneError" # ------------------------------------------------------------------ # # Aplicación de correcciones # ------------------------------------------------------------------ # @transaction.atomic def _apply_fix(self, partida, corregibles, marcar_no_descargada, pedimento_app): """ Renombra/reclasifica documentos y actualiza la partida en una transacción. Nota: si la transacción revierte, los cambios en storage NO se deshacen; re-ejecutar el comando converge (ver _rename_in_storage). """ for doc, clase in corregibles: suffix, doc_type = _RECLASIFICACION[clase] new_name = self._pick_target_name(doc, pedimento_app, partida.numero_partida, suffix) final_name = self._rename_in_storage(doc.archivo.name, new_name) doc.archivo = final_name doc.document_type_id = doc_type doc.vu = True doc.save(update_fields=["archivo", "document_type_id", "vu"]) self.stdout.write(self.style.SUCCESS( f" ✓ Doc {doc.id}: type={doc_type} | {final_name}" )) if marcar_no_descargada: partida.descargado = False partida.save(update_fields=["descargado"]) self.stdout.write(self.style.SUCCESS( f" ✓ Partida {partida.numero_partida}: descargado=False" )) def _pick_target_name(self, doc, pedimento_app, numero_partida, suffix): """ Primer nombre libre con nomenclatura {dir}/vu_PT_{pedimento_app}_{numero_partida}_{SUFFIX}[_{n}].xml verificado contra BD (excluyendo el propio doc) para que dos Documents nunca terminen apuntando al mismo archivo (p. ej. contra el REQUEST real type 17 que ya usa el nombre sin índice). """ dir_part = posixpath.dirname(doc.archivo.name) index = 0 while True: tail = f"_{index}" if index else "" candidate = posixpath.join( dir_part, f"vu_PT_{pedimento_app}_{numero_partida}_{suffix}{tail}.xml" ) if candidate == doc.archivo.name: return candidate if not Document.objects.filter(archivo=candidate).exclude(id=doc.id).exists(): return candidate index += 1 def _rename_in_storage(self, old_name, new_name): if old_name == new_name: return old_name if minio_client.file_exists(new_name): # Rename ya ocurrió en ejecución previa parcial self.stderr.write(self.style.WARNING( f" ⚠ Destino ya existe en storage, usando: {new_name}" )) if minio_client.file_exists(old_name): minio_client.delete_file(old_name) return new_name if not minio_client.file_exists(old_name): self.stderr.write(self.style.WARNING( f" ⚠ Archivo no encontrado en storage: {old_name}" )) return old_name response = minio_client._client.get_object(minio_client._bucket_name, old_name) try: content = response.read() finally: response.close() response.release_conn() minio_client.upload_file(new_name, file_data=io.BytesIO(content), content_type="application/xml") minio_client.delete_file(old_name) return new_name # ------------------------------------------------------------------ # # Resumen final # ------------------------------------------------------------------ # def _print_summary(self, total_peds, stats, dry_run): self.stdout.write( f"\n{'─' * 60}\nRESUMEN\n" f" Pedimentos procesados : {total_peds}\n" f" Partidas revisadas (descargado=True) : {stats['partidas']}\n" f" Partidas corregidas (descargado=False) : {stats['corregidas']}\n" f" Partidas sin cambios (no verificables) : {stats['bloqueadas']}\n" f" Docs renombrados a ERROR (type 18) : {stats['docs_error']}\n" f" Docs reclasificados a REQUEST (type 17): {stats['docs_request']}\n" f" Docs con contenido desconocido : {stats['desconocidos']}\n" f" Registros en BD sin archivo en storage : {stats['fantasmas']} (no se borran)\n" ) if dry_run: self.stdout.write(self.style.WARNING( "\nMODO PRUEBA: ejecuta sin --dry-run para aplicar los cambios." )) else: self.stdout.write(self.style.SUCCESS("\nCorrección completada."))