""" Diagnóstico y corrección de partidas con descargado=True cuyos documentos de respuesta VUCEM contienen true. 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 Acciones por cada documento con error VUCEM encontrado: - document_type_id: actual → 18 (PT ERROR) - archivo: renombrado a vu_PT_{pedimento_app}_{partida}_ERROR.xml - Partida.descargado: True → False Criterio de pedimento malformado (cualquiera de): - aduana: nulo/vacío o len < 3 - numero_operacion: nulo o vacío - patente: nulo/vacío o len < 4 - pedimento (campo): nulo/vacío o len < 7 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 --dry-run # todas las orgs """ import io import posixpath 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 class Command(BaseCommand): help = "Corrección de partidas descargado=True con respuestas de error VUCEM." 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.", ) # Control de lote parser.add_argument( "--offset", type=int, default=0, help="Saltar los primeros N pedimentos malformados (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 ped_qs = self._malformed_qs() 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 = ped_qs.count() if not (offset or limit) else min( limit or total_sin_filtro, max(0, total_sin_filtro - offset) ) self.stdout.write( f"Pedimentos malformados (total): {total_sin_filtro}\n" f"Procesando este lote : {total}" + (f" [offset={offset}]" if offset else "") + (f" [limit={limit}]" if limit else "") + "\n" ) if total == 0: self.stdout.write(self.style.SUCCESS("Nada que corregir en este lote.")) return total_partidas = total_docs = 0 for ped in ped_qs: p, d = self._process_pedimento(ped, dry_run) total_partidas += p total_docs += d self._print_summary(total, total_partidas, total_docs, 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.") checks = self._field_checks(ped) self._print_ped_diagnosis(ped, checks) if not any(checks.values()): return self._process_pedimento(ped, 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 malformado # ------------------------------------------------------------------ # def _process_pedimento(self, ped, dry_run): self.stdout.write( f"Pedimento: {ped.pedimento_app} | " f"aduana={ped.aduana!r} patente={ped.patente!r} num_op={ped.numero_operacion!r}" ) 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 0, 0 self.stdout.write(f" Partidas con descargado=True: {n_partidas}") total_docs_error = 0 for partida in partidas: # Documentos de respuesta: excluir REQUEST (17) y los ya marcados ERROR (18) patron = f"vu_PT_{ped.pedimento_app}_{partida.numero_partida}_" candidatos = list( Document.objects.filter( pedimento=ped, archivo__icontains=patron, ).exclude(document_type_id__in=[_PT_REQUEST, _PT_ERROR]) ) self.stdout.write( f"\n Partida {partida.numero_partida}: {len(candidatos)} doc(s) candidatos a revisar" ) docs_con_error = [] for doc in candidatos: # estado: "error" | "ok" | "no_verificable" estado, motivo = self._check_vucem_error(doc) if estado == "error": icono = self.style.ERROR("✗ ERROR VUCEM") elif estado == "ok": icono = self.style.SUCCESS("✓ ok") else: icono = self.style.WARNING("⚠ sin archivo en storage") self.stdout.write(f" [{icono}] type={doc.document_type_id} | {doc.archivo.name}") if estado == "error": self.stdout.write(f" motivo : {motivo}") new_name = self._build_error_filename( doc.archivo.name, ped.pedimento_app, partida.numero_partida, len(docs_con_error) ) self.stdout.write(f" → {new_name}") docs_con_error.append(doc) elif estado == "no_verificable": self.stdout.write(f" {motivo} — ejecuta en producción para verificar") total_docs_error += len(docs_con_error) if not dry_run and docs_con_error: self._apply_fix(partida, docs_con_error, ped.pedimento_app) self.stdout.write("") return n_partidas, total_docs_error # ------------------------------------------------------------------ # # Detección de error VUCEM en el XML # ------------------------------------------------------------------ # def _check_vucem_error(self, doc): """ Lee el XML desde MinIO y verifica si VUCEM devolvió un error. Retorna ("error" | "ok" | "no_verificable", motivo: str | None). """ try: name = doc.archivo.name if not minio_client.file_exists(name): return "no_verificable", "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") if "tieneError>true<" in text: return "error", "tieneError=true detectado en XML" return "ok", None except Exception as e: return "no_verificable", f"excepción al leer archivo: {e}" # ------------------------------------------------------------------ # # Construcción del nombre de archivo de error # ------------------------------------------------------------------ # def _build_error_filename(self, old_name, pedimento_app, numero_partida, index=0): """ Retorna la ruta con nomenclatura de error: index=0 → {dir}/vu_PT_{pedimento_app}_{numero_partida}_ERROR.xml index>0 → {dir}/vu_PT_{pedimento_app}_{numero_partida}_ERROR_{index}.xml El índice evita colisión cuando una partida tiene más de un doc con error. """ dir_part = posixpath.dirname(old_name) suffix = f"_{index}" if index > 0 else "" new_filename = f"vu_PT_{pedimento_app}_{numero_partida}_ERROR{suffix}.xml" return posixpath.join(dir_part, new_filename) # ------------------------------------------------------------------ # # Aplicación de correcciones # ------------------------------------------------------------------ # @transaction.atomic def _apply_fix(self, partida, docs, pedimento_app): """ Renombra archivos en storage y actualiza BD dentro de una transacción. Nota: si la transacción revierte, los cambios en storage NO se deshacen. """ for idx, doc in enumerate(docs): new_name = self._build_error_filename( doc.archivo.name, pedimento_app, partida.numero_partida, idx ) final_name = self._rename_in_storage(doc.archivo.name, new_name) doc.archivo = final_name doc.document_type_id = _PT_ERROR doc.vu = True doc.save(update_fields=["archivo", "document_type_id", "vu"]) self.stdout.write(self.style.SUCCESS( f" ✓ Doc {doc.id}: type=18 | {final_name}" )) partida.descargado = False partida.save(update_fields=["descargado"]) self.stdout.write(self.style.SUCCESS( f" ✓ Partida {partida.numero_partida}: descargado=False" )) 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" ⚠ ERROR 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, total_partidas, total_docs, dry_run): self.stdout.write( f"\n{'─' * 60}\nRESUMEN\n" f" Pedimentos malformados : {total_peds}\n" f" Partidas con descargado=True : {total_partidas}\n" f" Documentos con error VUCEM : {total_docs}\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."))