383 lines
15 KiB
Python
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."))
|