Files
backend/api/customs/management/commands/fix_partidas_error.py

383 lines
15 KiB
Python

"""
Diagnóstico y corrección de partidas con descargado=True cuyos documentos
de respuesta VUCEM contienen <tieneError>true</tieneError>.
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 <UUID> --dry-run
python manage.py fix_partidas_error --organizacion <UUID> --dry-run
python manage.py fix_partidas_error --organizacion <UUID>
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."))