fix/de los tickets T2026-05-027, T2025-09-004 y T2025-09-056
This commit is contained in:
@@ -1,31 +1,52 @@
|
||||
"""
|
||||
Diagnóstico y corrección de partidas con descargado=True cuyos documentos
|
||||
de respuesta VUCEM contienen <tieneError>true</tieneError>.
|
||||
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 <consultarPartidaRespuesta> sin <tieneError>true</tieneError>.
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
(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 <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 --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
|
||||
@@ -39,9 +60,23 @@ 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 = "Corrección de partidas descargado=True con respuestas de error VUCEM."
|
||||
help = "Corrige partidas descargado=True sin XML de respuesta de partida válido."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
@@ -61,10 +96,14 @@ class Command(BaseCommand):
|
||||
"--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 malformados (default: 0).",
|
||||
help="Saltar los primeros N pedimentos (default: 0).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit", type=int, default=0,
|
||||
@@ -97,7 +136,12 @@ class Command(BaseCommand):
|
||||
self._handle_single(ped_id, dry_run)
|
||||
return
|
||||
|
||||
ped_qs = self._malformed_qs()
|
||||
# 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)
|
||||
@@ -115,29 +159,32 @@ class Command(BaseCommand):
|
||||
if limit:
|
||||
ped_qs = ped_qs[:limit]
|
||||
|
||||
total = ped_qs.count() if not (offset or limit) else min(
|
||||
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 malformados (total): {total_sin_filtro}\n"
|
||||
f"Procesando este lote : {total}"
|
||||
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 corregir en este lote."))
|
||||
self.stdout.write(self.style.SUCCESS("Nada que revisar en este lote."))
|
||||
return
|
||||
|
||||
total_partidas = total_docs = 0
|
||||
stats = self._stats_vacios()
|
||||
n_peds = 0
|
||||
for ped in ped_qs:
|
||||
p, d = self._process_pedimento(ped, dry_run)
|
||||
total_partidas += p
|
||||
total_docs += d
|
||||
parciales = self._process_pedimento(ped, dry_run)
|
||||
n_peds += 1
|
||||
for k in stats:
|
||||
stats[k] += parciales[k]
|
||||
|
||||
self._print_summary(total, total_partidas, total_docs, dry_run)
|
||||
self._print_summary(n_peds, stats, dry_run)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Flujo --pedimento
|
||||
@@ -149,11 +196,10 @@ class Command(BaseCommand):
|
||||
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)
|
||||
# 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
|
||||
@@ -199,139 +245,247 @@ class Command(BaseCommand):
|
||||
self.stdout.write("")
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Procesamiento de un pedimento malformado
|
||||
# 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 0, 0
|
||||
return stats
|
||||
|
||||
self.stdout.write(f" Partidas con descargado=True: {n_partidas}")
|
||||
total_docs_error = 0
|
||||
|
||||
# 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:
|
||||
# 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._process_partida(ped, partida, docs_pedimento, storage_es_correcto, dry_run, stats)
|
||||
|
||||
self.stdout.write("")
|
||||
return n_partidas, total_docs_error
|
||||
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
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Detección de error VUCEM en el XML
|
||||
# Procesamiento de una partida
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _check_vucem_error(self, doc):
|
||||
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):
|
||||
"""
|
||||
Lee el XML desde MinIO y verifica si VUCEM devolvió un error.
|
||||
Retorna ("error" | "ok" | "no_verificable", motivo: str | None).
|
||||
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:
|
||||
name = doc.archivo.name
|
||||
if not minio_client.file_exists(name):
|
||||
return "no_verificable", "archivo no encontrado en storage"
|
||||
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")
|
||||
if "tieneError>true<" in text:
|
||||
return "error", "tieneError=true detectado en XML"
|
||||
return "ok", None
|
||||
text = content.decode("utf-8", errors="replace").lower()
|
||||
except Exception as e:
|
||||
return "no_verificable", f"excepción al leer archivo: {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)
|
||||
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, docs, pedimento_app):
|
||||
def _apply_fix(self, partida, corregibles, marcar_no_descargada, 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.
|
||||
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 idx, doc in enumerate(docs):
|
||||
new_name = self._build_error_filename(
|
||||
doc.archivo.name, pedimento_app, partida.numero_partida, idx
|
||||
)
|
||||
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 = _PT_ERROR
|
||||
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=18 | {final_name}"
|
||||
f" ✓ Doc {doc.id}: type={doc_type} | {final_name}"
|
||||
))
|
||||
|
||||
partida.descargado = False
|
||||
partida.save(update_fields=["descargado"])
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f" ✓ Partida {partida.numero_partida}: descargado=False"
|
||||
))
|
||||
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:
|
||||
@@ -340,7 +494,7 @@ class Command(BaseCommand):
|
||||
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}"
|
||||
f" ⚠ Destino ya existe en storage, usando: {new_name}"
|
||||
))
|
||||
if minio_client.file_exists(old_name):
|
||||
minio_client.delete_file(old_name)
|
||||
@@ -367,12 +521,17 @@ class Command(BaseCommand):
|
||||
# Resumen final
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _print_summary(self, total_peds, total_partidas, total_docs, dry_run):
|
||||
def _print_summary(self, total_peds, stats, 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"
|
||||
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(
|
||||
|
||||
110
api/customs/management/commands/reconciliar_descargas.py
Normal file
110
api/customs/management/commands/reconciliar_descargas.py
Normal file
@@ -0,0 +1,110 @@
|
||||
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 <uuid>
|
||||
"""
|
||||
|
||||
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"))
|
||||
99
api/customs/migrations/0020_estados_descarga_t2026_05_027.py
Normal file
99
api/customs/migrations/0020_estados_descarga_t2026_05_027.py
Normal file
@@ -0,0 +1,99 @@
|
||||
# Migración T2026-05-027: estados de descarga de 3 valores (pendiente/descargado/error)
|
||||
# y contador de intentos automáticos para EDocument y Cove.
|
||||
#
|
||||
# NO aplicar en automático. Después de aplicarla, ejecutar el backfill:
|
||||
# backend/scripts/t2026_05_027/02_backfill_estados.sql
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('customs', '0019_pedimento_consultar_vucem'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# --- EDocument ---
|
||||
migrations.AddField(
|
||||
model_name='edocument',
|
||||
name='edocument_estado',
|
||||
field=models.CharField(choices=[('pendiente', 'Pendiente'), ('descargado', 'Descargado'), ('error', 'Error')], default='pendiente', help_text='Estado de descarga del e-documento: pendiente, descargado o error', max_length=12),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='edocument',
|
||||
name='acuse_estado',
|
||||
field=models.CharField(choices=[('pendiente', 'Pendiente'), ('descargado', 'Descargado'), ('error', 'Error')], default='pendiente', help_text='Estado de descarga del acuse: pendiente, descargado o error', max_length=12),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='edocument',
|
||||
name='edocument_intentos',
|
||||
field=models.PositiveSmallIntegerField(default=0, help_text='Intentos automáticos de descarga del e-documento (un ciclo de orquestación = un intento)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='edocument',
|
||||
name='acuse_intentos',
|
||||
field=models.PositiveSmallIntegerField(default=0, help_text='Intentos automáticos de descarga del acuse (un ciclo de orquestación = un intento)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='edocument',
|
||||
name='ultimo_intento_at',
|
||||
field=models.DateTimeField(blank=True, help_text='Fecha del último intento automático de descarga', null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='edocument',
|
||||
name='ultimo_error',
|
||||
field=models.TextField(blank=True, help_text='Detalle del último error de descarga', null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='edocument',
|
||||
name='edocument_descargado',
|
||||
field=models.BooleanField(default=False, help_text='Indica si el e-documento ha sido descargado (legado, derivado de edocument_estado)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='edocument',
|
||||
name='acuse_descargado',
|
||||
field=models.BooleanField(default=False, help_text='Indica si el acuse del e-documento ha sido descargado (legado, derivado de acuse_estado)'),
|
||||
),
|
||||
# --- Cove ---
|
||||
migrations.AddField(
|
||||
model_name='cove',
|
||||
name='cove_estado',
|
||||
field=models.CharField(choices=[('pendiente', 'Pendiente'), ('descargado', 'Descargado'), ('error', 'Error')], default='pendiente', help_text='Estado de descarga de la cove: pendiente, descargado o error', max_length=12),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cove',
|
||||
name='acuse_cove_estado',
|
||||
field=models.CharField(choices=[('pendiente', 'Pendiente'), ('descargado', 'Descargado'), ('error', 'Error')], default='pendiente', help_text='Estado de descarga del acuse de la cove: pendiente, descargado o error', max_length=12),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cove',
|
||||
name='cove_intentos',
|
||||
field=models.PositiveSmallIntegerField(default=0, help_text='Intentos automáticos de descarga de la cove (un ciclo de orquestación = un intento)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cove',
|
||||
name='acuse_cove_intentos',
|
||||
field=models.PositiveSmallIntegerField(default=0, help_text='Intentos automáticos de descarga del acuse de la cove (un ciclo de orquestación = un intento)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cove',
|
||||
name='ultimo_intento_at',
|
||||
field=models.DateTimeField(blank=True, help_text='Fecha del último intento automático de descarga', null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cove',
|
||||
name='ultimo_error',
|
||||
field=models.TextField(blank=True, help_text='Detalle del último error de descarga', null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='cove',
|
||||
name='cove_descargado',
|
||||
field=models.BooleanField(default=False, help_text='Indica si la cove ha sido descargada (legado, derivado de cove_estado)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='cove',
|
||||
name='acuse_cove_descargado',
|
||||
field=models.BooleanField(default=False, help_text='Indica si el acuse de la cove ha sido descargado (legado, derivado de acuse_cove_estado)'),
|
||||
),
|
||||
]
|
||||
@@ -66,6 +66,13 @@ class Pedimento(models.Model):
|
||||
['organizacion', 'pedimento_app']
|
||||
]
|
||||
|
||||
class EstadoDescarga(models.TextChoices):
|
||||
"""Estado de descarga de documentos VUCEM (requerimiento T2026-05-027):
|
||||
'error' indica que la descarga no pudo completarse y requiere atención."""
|
||||
PENDIENTE = 'pendiente', 'Pendiente'
|
||||
DESCARGADO = 'descargado', 'Descargado'
|
||||
ERROR = 'error', 'Error'
|
||||
|
||||
class Partida(models.Model):
|
||||
pedimento = models.ForeignKey(Pedimento, on_delete=models.CASCADE, related_name='partidas', help_text="Pedimento asociado a la partida")
|
||||
organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='partidas', help_text="Organización a la que pertenece la partida")
|
||||
@@ -94,8 +101,28 @@ class EDocument(models.Model):
|
||||
descripcion = models.CharField(max_length=200, blank=True, null=True, help_text="Descripción del documento")
|
||||
created_at = models.DateTimeField(auto_now_add=True, help_text="Fecha de creación del documento")
|
||||
updated_at = models.DateTimeField(auto_now=True, help_text="Fecha de última actualización del documento")
|
||||
edocument_descargado = models.BooleanField(default=False, help_text="Indica si el e-documento ha sido descargado")
|
||||
acuse_descargado = models.BooleanField(default=False, help_text="Indica si el acuse del e-documento ha sido descargado")
|
||||
edocument_descargado = models.BooleanField(default=False, help_text="Indica si el e-documento ha sido descargado (legado, derivado de edocument_estado)")
|
||||
acuse_descargado = models.BooleanField(default=False, help_text="Indica si el acuse del e-documento ha sido descargado (legado, derivado de acuse_estado)")
|
||||
edocument_estado = models.CharField(max_length=12, choices=EstadoDescarga.choices, default=EstadoDescarga.PENDIENTE, help_text="Estado de descarga del e-documento: pendiente, descargado o error")
|
||||
acuse_estado = models.CharField(max_length=12, choices=EstadoDescarga.choices, default=EstadoDescarga.PENDIENTE, help_text="Estado de descarga del acuse: pendiente, descargado o error")
|
||||
edocument_intentos = models.PositiveSmallIntegerField(default=0, help_text="Intentos automáticos de descarga del e-documento (un ciclo de orquestación = un intento)")
|
||||
acuse_intentos = models.PositiveSmallIntegerField(default=0, help_text="Intentos automáticos de descarga del acuse (un ciclo de orquestación = un intento)")
|
||||
ultimo_intento_at = models.DateTimeField(null=True, blank=True, help_text="Fecha del último intento automático de descarga")
|
||||
ultimo_error = models.TextField(null=True, blank=True, help_text="Detalle del último error de descarga")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# El estado de 3 valores es la fuente de verdad; los booleanos legados se derivan
|
||||
self.edocument_descargado = self.edocument_estado == EstadoDescarga.DESCARGADO
|
||||
self.acuse_descargado = self.acuse_estado == EstadoDescarga.DESCARGADO
|
||||
update_fields = kwargs.get('update_fields')
|
||||
if update_fields is not None:
|
||||
update_fields = set(update_fields)
|
||||
if 'edocument_estado' in update_fields:
|
||||
update_fields.add('edocument_descargado')
|
||||
if 'acuse_estado' in update_fields:
|
||||
update_fields.add('acuse_descargado')
|
||||
kwargs['update_fields'] = list(update_fields)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.descripcion} - {self.pedimento.pedimento}"
|
||||
@@ -112,8 +139,28 @@ class Cove(models.Model):
|
||||
numero_cove = models.CharField(max_length=20, unique=True, help_text="Número único de la cove")
|
||||
created_at = models.DateTimeField(auto_now_add=True, help_text="Fecha de creación de la cove")
|
||||
updated_at = models.DateTimeField(auto_now=True, help_text="Fecha de última actualización de la cove")
|
||||
cove_descargado = models.BooleanField(default=False, help_text="Indica si la cove ha sido descargada")
|
||||
acuse_cove_descargado = models.BooleanField(default=False, help_text="Indica si el acuse de la cove ha sido descargado")
|
||||
cove_descargado = models.BooleanField(default=False, help_text="Indica si la cove ha sido descargada (legado, derivado de cove_estado)")
|
||||
acuse_cove_descargado = models.BooleanField(default=False, help_text="Indica si el acuse de la cove ha sido descargado (legado, derivado de acuse_cove_estado)")
|
||||
cove_estado = models.CharField(max_length=12, choices=EstadoDescarga.choices, default=EstadoDescarga.PENDIENTE, help_text="Estado de descarga de la cove: pendiente, descargado o error")
|
||||
acuse_cove_estado = models.CharField(max_length=12, choices=EstadoDescarga.choices, default=EstadoDescarga.PENDIENTE, help_text="Estado de descarga del acuse de la cove: pendiente, descargado o error")
|
||||
cove_intentos = models.PositiveSmallIntegerField(default=0, help_text="Intentos automáticos de descarga de la cove (un ciclo de orquestación = un intento)")
|
||||
acuse_cove_intentos = models.PositiveSmallIntegerField(default=0, help_text="Intentos automáticos de descarga del acuse de la cove (un ciclo de orquestación = un intento)")
|
||||
ultimo_intento_at = models.DateTimeField(null=True, blank=True, help_text="Fecha del último intento automático de descarga")
|
||||
ultimo_error = models.TextField(null=True, blank=True, help_text="Detalle del último error de descarga")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# El estado de 3 valores es la fuente de verdad; los booleanos legados se derivan
|
||||
self.cove_descargado = self.cove_estado == EstadoDescarga.DESCARGADO
|
||||
self.acuse_cove_descargado = self.acuse_cove_estado == EstadoDescarga.DESCARGADO
|
||||
update_fields = kwargs.get('update_fields')
|
||||
if update_fields is not None:
|
||||
update_fields = set(update_fields)
|
||||
if 'cove_estado' in update_fields:
|
||||
update_fields.add('cove_descargado')
|
||||
if 'acuse_cove_estado' in update_fields:
|
||||
update_fields.add('acuse_cove_descargado')
|
||||
kwargs['update_fields'] = list(update_fields)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.numero_cove} - {self.pedimento.pedimento}"
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
from rest_framework import serializers
|
||||
from api.customs.models import (
|
||||
Pedimento,
|
||||
TipoOperacion,
|
||||
ProcesamientoPedimento,
|
||||
Pedimento,
|
||||
TipoOperacion,
|
||||
ProcesamientoPedimento,
|
||||
EDocument,
|
||||
Cove,
|
||||
Importador,
|
||||
Partida
|
||||
Partida,
|
||||
EstadoDescarga
|
||||
)
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
@@ -205,7 +206,23 @@ class EDocumentSerializer(serializers.ModelSerializer):
|
||||
model = EDocument
|
||||
fields = '__all__'
|
||||
read_only_fields = ('created_at', 'updated_at')
|
||||
|
||||
|
||||
def validate(self, attrs):
|
||||
# Compatibilidad: payloads legados que solo mandan los booleanos se traducen
|
||||
# al estado de 3 valores (fuente de verdad en el modelo). Un False legado no
|
||||
# degrada un estado 'error' ya asignado.
|
||||
if 'edocument_descargado' in attrs and 'edocument_estado' not in attrs:
|
||||
if attrs['edocument_descargado']:
|
||||
attrs['edocument_estado'] = EstadoDescarga.DESCARGADO
|
||||
elif not (self.instance and self.instance.edocument_estado == EstadoDescarga.ERROR):
|
||||
attrs['edocument_estado'] = EstadoDescarga.PENDIENTE
|
||||
if 'acuse_descargado' in attrs and 'acuse_estado' not in attrs:
|
||||
if attrs['acuse_descargado']:
|
||||
attrs['acuse_estado'] = EstadoDescarga.DESCARGADO
|
||||
elif not (self.instance and self.instance.acuse_estado == EstadoDescarga.ERROR):
|
||||
attrs['acuse_estado'] = EstadoDescarga.PENDIENTE
|
||||
return attrs
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Si no es superusuario, hacer organizacion read_only
|
||||
@@ -221,6 +238,22 @@ class CoveSerializer(serializers.ModelSerializer):
|
||||
fields = '__all__'
|
||||
read_only_fields = ('created_at', 'updated_at')
|
||||
|
||||
def validate(self, attrs):
|
||||
# Compatibilidad: payloads legados que solo mandan los booleanos se traducen
|
||||
# al estado de 3 valores (fuente de verdad en el modelo). Un False legado no
|
||||
# degrada un estado 'error' ya asignado.
|
||||
if 'cove_descargado' in attrs and 'cove_estado' not in attrs:
|
||||
if attrs['cove_descargado']:
|
||||
attrs['cove_estado'] = EstadoDescarga.DESCARGADO
|
||||
elif not (self.instance and self.instance.cove_estado == EstadoDescarga.ERROR):
|
||||
attrs['cove_estado'] = EstadoDescarga.PENDIENTE
|
||||
if 'acuse_cove_descargado' in attrs and 'acuse_cove_estado' not in attrs:
|
||||
if attrs['acuse_cove_descargado']:
|
||||
attrs['acuse_cove_estado'] = EstadoDescarga.DESCARGADO
|
||||
elif not (self.instance and self.instance.acuse_cove_estado == EstadoDescarga.ERROR):
|
||||
attrs['acuse_cove_estado'] = EstadoDescarga.PENDIENTE
|
||||
return attrs
|
||||
|
||||
def get_documentos(self, obj):
|
||||
"""
|
||||
Busca documentos en la tabla `document` que coincidan con el
|
||||
|
||||
@@ -988,25 +988,61 @@ def auditar_integridad_coves_por_pedimento(pedimento_id):
|
||||
|
||||
@shared_task
|
||||
def auditar_integridad_remesa_por_pedimento(pedimento_id):
|
||||
"""Verifica que los COVEs del XML de remesa existan en DB para un pedimento específico."""
|
||||
"""Verifica que los COVEs del XML de remesa existan en DB para un pedimento específico.
|
||||
|
||||
Deduce si el pedimento es consolidado desde el identificador PC del XML del
|
||||
pedimento completo (fuente de verdad) en lugar del flag `remesas`. Si es
|
||||
consolidado y no hay documento de remesa descargado, dispara la consulta a VUCEM.
|
||||
"""
|
||||
# Import local para evitar import circular (internal_services importa de auditoria)
|
||||
from api.customs.tasks.internal_services import crear_procesamiento_remesa
|
||||
|
||||
try:
|
||||
pedimento = Pedimento.objects.get(id=pedimento_id)
|
||||
|
||||
if not pedimento.remesas:
|
||||
xml_pc = _leer_xml_pedimento_completo(pedimento)
|
||||
if not xml_pc:
|
||||
return {
|
||||
'pedimento_id': str(pedimento_id),
|
||||
'pedimento': pedimento.pedimento,
|
||||
'estado': 'sin_xml_pc',
|
||||
'mensaje': 'No hay pedimento completo (document_type=2) descargado',
|
||||
}
|
||||
|
||||
xml_data = xml_controller.extract_data(xml_pc)
|
||||
if not xml_data:
|
||||
return {
|
||||
'pedimento_id': str(pedimento_id),
|
||||
'pedimento': pedimento.pedimento,
|
||||
'estado': 'error',
|
||||
'mensaje': 'No se pudieron extraer datos del XML del pedimento completo',
|
||||
}
|
||||
|
||||
tiene_remesas = bool(xml_data.get('remesas'))
|
||||
|
||||
# Sincronizar el flag con queryset.update() para no disparar el signal
|
||||
# post_save; la consulta a VUCEM se dispara explícitamente abajo
|
||||
if tiene_remesas != pedimento.remesas:
|
||||
Pedimento.objects.filter(id=pedimento.id).update(remesas=tiene_remesas)
|
||||
pedimento.remesas = tiene_remesas
|
||||
|
||||
if not tiene_remesas:
|
||||
return {
|
||||
'pedimento_id': str(pedimento_id),
|
||||
'pedimento': pedimento.pedimento,
|
||||
'estado': 'sin_remesas',
|
||||
'mensaje': 'Este pedimento no tiene remesas',
|
||||
'mensaje': 'El pedimento completo no declara identificador PC (consolidado)',
|
||||
}
|
||||
|
||||
doc_remesa = pedimento.documents.filter(document_type=3).first()
|
||||
if not doc_remesa:
|
||||
# Consolidado sin XML de remesa: solicitar la descarga a VUCEM
|
||||
crear_procesamiento_remesa.apply_async(args=[str(pedimento.id)])
|
||||
return {
|
||||
'pedimento_id': str(pedimento_id),
|
||||
'pedimento': pedimento.pedimento,
|
||||
'estado': 'sin_xml',
|
||||
'mensaje': 'No hay documento de remesa (document_type=3) descargado',
|
||||
'estado': 'descarga_solicitada',
|
||||
'mensaje': 'Pedimento consolidado sin documento de remesa; se solicitó la consulta a VUCEM',
|
||||
}
|
||||
|
||||
remesa_xml = _leer_xml_documento(doc_remesa)
|
||||
|
||||
@@ -5,8 +5,10 @@ from api.customs.models import *
|
||||
from api.record.models import *
|
||||
from api.customs.serializers import PedimentoSerializer
|
||||
from api.vucem.models import *
|
||||
from django.db.models import F
|
||||
from django.utils import timezone
|
||||
import requests
|
||||
from config.settings import SERVICE_API_URL_V2
|
||||
from config.settings import SERVICE_API_URL_V2, MAX_INTENTOS_AUTO
|
||||
from datetime import datetime
|
||||
import json
|
||||
import logging
|
||||
@@ -77,16 +79,18 @@ def partida_to_dict(partida):
|
||||
|
||||
@shared_task
|
||||
def procesar_coves_pedimento(pedimento_id):
|
||||
# Flujo manual: incluye registros en 'error' y no aplica tope ni contador de intentos
|
||||
pedimento = Pedimento.objects.get(id=pedimento_id)
|
||||
if pedimento.coves.filter(cove_descargado=False).exists():
|
||||
estados_reprocesables = [EstadoDescarga.PENDIENTE, EstadoDescarga.ERROR]
|
||||
if pedimento.coves.filter(cove_estado__in=estados_reprocesables).exists():
|
||||
pedimento_dict = pedimento_to_dict(pedimento)
|
||||
credenciales = Vucem.objects.filter(
|
||||
id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id
|
||||
).first()
|
||||
credenciales_dict = credenciales_to_dict(credenciales)
|
||||
|
||||
|
||||
payload = {
|
||||
"coves": [cove_to_dict(cove) for cove in pedimento.coves.filter(cove_descargado=False)],
|
||||
"coves": [cove_to_dict(cove) for cove in pedimento.coves.filter(cove_estado__in=estados_reprocesables)],
|
||||
"pedimento": pedimento_dict,
|
||||
"credencial": credenciales_dict
|
||||
}
|
||||
@@ -106,8 +110,10 @@ def procesar_coves_pedimento(pedimento_id):
|
||||
|
||||
@shared_task
|
||||
def procesar_acuse_coves_pedimento(pedimento_id):
|
||||
# Flujo manual: incluye registros en 'error' y no aplica tope ni contador de intentos
|
||||
pedimento = Pedimento.objects.get(id=pedimento_id)
|
||||
if pedimento.coves.filter(acuse_cove_descargado=False).exists():
|
||||
estados_reprocesables = [EstadoDescarga.PENDIENTE, EstadoDescarga.ERROR]
|
||||
if pedimento.coves.filter(acuse_cove_estado__in=estados_reprocesables).exists():
|
||||
pedimento_dict = pedimento_to_dict(pedimento)
|
||||
credenciales = Vucem.objects.filter(
|
||||
id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id
|
||||
@@ -115,7 +121,7 @@ def procesar_acuse_coves_pedimento(pedimento_id):
|
||||
credenciales_dict = credenciales_to_dict(credenciales)
|
||||
|
||||
payload = {
|
||||
"coves": [cove_to_dict(cove) for cove in pedimento.coves.filter(acuse_cove_descargado=False)],
|
||||
"coves": [cove_to_dict(cove) for cove in pedimento.coves.filter(acuse_cove_estado__in=estados_reprocesables)],
|
||||
"pedimento": pedimento_dict,
|
||||
"credencial": credenciales_dict
|
||||
}
|
||||
@@ -135,8 +141,10 @@ def procesar_acuse_coves_pedimento(pedimento_id):
|
||||
|
||||
@shared_task
|
||||
def procesar_edocs_pedimento(pedimento_id):
|
||||
# Flujo manual: incluye registros en 'error' y no aplica tope ni contador de intentos
|
||||
pedimento = Pedimento.objects.get(id=pedimento_id)
|
||||
if pedimento.documentos.filter(edocument_descargado=False).exists():
|
||||
estados_reprocesables = [EstadoDescarga.PENDIENTE, EstadoDescarga.ERROR]
|
||||
if pedimento.documentos.filter(edocument_estado__in=estados_reprocesables).exists():
|
||||
pedimento_dict = pedimento_to_dict(pedimento)
|
||||
credenciales = Vucem.objects.filter(
|
||||
id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id
|
||||
@@ -144,7 +152,7 @@ def procesar_edocs_pedimento(pedimento_id):
|
||||
credenciales_dict = credenciales_to_dict(credenciales)
|
||||
|
||||
payload = {
|
||||
"edocs": [edoc_to_dict(edoc) for edoc in pedimento.documentos.filter(edocument_descargado=False)],
|
||||
"edocs": [edoc_to_dict(edoc) for edoc in pedimento.documentos.filter(edocument_estado__in=estados_reprocesables)],
|
||||
"pedimento": pedimento_dict,
|
||||
"credencial": credenciales_dict
|
||||
}
|
||||
@@ -164,8 +172,10 @@ def procesar_edocs_pedimento(pedimento_id):
|
||||
|
||||
@shared_task
|
||||
def procesar_acuses_pedimento(pedimento_id):
|
||||
# Flujo manual: incluye registros en 'error' y no aplica tope ni contador de intentos
|
||||
pedimento = Pedimento.objects.get(id=pedimento_id)
|
||||
if pedimento.documentos.filter(acuse_descargado=False).exists():
|
||||
estados_reprocesables = [EstadoDescarga.PENDIENTE, EstadoDescarga.ERROR]
|
||||
if pedimento.documentos.filter(acuse_estado__in=estados_reprocesables).exists():
|
||||
pedimento_dict = pedimento_to_dict(pedimento)
|
||||
credenciales = Vucem.objects.filter(
|
||||
id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id
|
||||
@@ -173,7 +183,7 @@ def procesar_acuses_pedimento(pedimento_id):
|
||||
credenciales_dict = credenciales_to_dict(credenciales)
|
||||
|
||||
payload = {
|
||||
"edocs": [edoc_to_dict(edoc) for edoc in pedimento.documentos.filter(acuse_descargado=False)],
|
||||
"edocs": [edoc_to_dict(edoc) for edoc in pedimento.documentos.filter(acuse_estado__in=estados_reprocesables)],
|
||||
"pedimento": pedimento_dict,
|
||||
"credencial": credenciales_dict
|
||||
}
|
||||
@@ -381,20 +391,31 @@ def procesar_coves(organizacion_id):
|
||||
coves__isnull=False
|
||||
).distinct()
|
||||
for pedimento in pedimentos:
|
||||
if pedimento.coves.filter(cove_descargado=False).exists(): # Tipo 3: Remesa
|
||||
# Compuerta del automático (T2026-05-027): solo 'pendiente' con intentos disponibles;
|
||||
# registros en 'error' o con tope agotado solo se relanzan de forma manual
|
||||
pendientes = pedimento.coves.filter(
|
||||
cove_estado=EstadoDescarga.PENDIENTE,
|
||||
cove_intentos__lt=MAX_INTENTOS_AUTO,
|
||||
)
|
||||
coves_batch = list(pendientes)
|
||||
if coves_batch:
|
||||
|
||||
# Convertir el pedimento a JSON usando el serializer
|
||||
pedimento_dict = pedimento_to_dict(pedimento)
|
||||
credenciales = Vucem.objects.filter(id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id).first()
|
||||
|
||||
|
||||
credenciales_dict = credenciales_to_dict(credenciales)
|
||||
|
||||
|
||||
payload = {
|
||||
"coves": [cove_to_dict(cove) for cove in pedimento.coves.filter(cove_descargado=False)],
|
||||
"coves": [cove_to_dict(cove) for cove in coves_batch],
|
||||
"pedimento": pedimento_dict,
|
||||
"credencial": credenciales_dict
|
||||
}
|
||||
|
||||
|
||||
# Un ciclo de orquestación = un intento; los reintentos internos
|
||||
# del worker (Celery/SOAP) pertenecen a este mismo intento
|
||||
pendientes.update(cove_intentos=F('cove_intentos') + 1, ultimo_intento_at=timezone.now())
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/all/coves",
|
||||
@@ -416,20 +437,29 @@ def procesar_acuse_coves(organizacion_id):
|
||||
).distinct()
|
||||
|
||||
for pedimento in pedimentos:
|
||||
if pedimento.coves.filter(acuse_cove_descargado=False).exists(): # Tipo 3: Remesa
|
||||
# Compuerta del automático (T2026-05-027): solo 'pendiente' con intentos disponibles
|
||||
pendientes = pedimento.coves.filter(
|
||||
acuse_cove_estado=EstadoDescarga.PENDIENTE,
|
||||
acuse_cove_intentos__lt=MAX_INTENTOS_AUTO,
|
||||
)
|
||||
coves_batch = list(pendientes)
|
||||
if coves_batch:
|
||||
|
||||
# Convertir el pedimento a JSON usando el serializer
|
||||
pedimento_dict = pedimento_to_dict(pedimento)
|
||||
credenciales = Vucem.objects.filter(id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id).first()
|
||||
|
||||
|
||||
credenciales_dict = credenciales_to_dict(credenciales)
|
||||
|
||||
|
||||
payload = {
|
||||
"coves": [cove_to_dict(cove) for cove in pedimento.coves.filter(acuse_cove_descargado=False)],
|
||||
"coves": [cove_to_dict(cove) for cove in coves_batch],
|
||||
"pedimento": pedimento_dict,
|
||||
"credencial": credenciales_dict
|
||||
}
|
||||
|
||||
|
||||
# Un ciclo de orquestación = un intento
|
||||
pendientes.update(acuse_cove_intentos=F('acuse_cove_intentos') + 1, ultimo_intento_at=timezone.now())
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/all/acuse/cove/",
|
||||
@@ -451,20 +481,29 @@ def procesar_acuses(organizacion_id):
|
||||
).distinct()
|
||||
|
||||
for pedimento in pedimentos:
|
||||
if pedimento.documentos.filter(acuse_descargado=False).exists(): # Tipo 3: Remesa
|
||||
# Compuerta del automático (T2026-05-027): solo 'pendiente' con intentos disponibles
|
||||
pendientes = pedimento.documentos.filter(
|
||||
acuse_estado=EstadoDescarga.PENDIENTE,
|
||||
acuse_intentos__lt=MAX_INTENTOS_AUTO,
|
||||
)
|
||||
edocs_batch = list(pendientes)
|
||||
if edocs_batch:
|
||||
|
||||
# Convertir el pedimento a JSON usando el serializer
|
||||
pedimento_dict = pedimento_to_dict(pedimento)
|
||||
credenciales = Vucem.objects.filter(id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id).first()
|
||||
|
||||
|
||||
credenciales_dict = credenciales_to_dict(credenciales)
|
||||
|
||||
|
||||
payload = {
|
||||
"edocs": [edoc_to_dict(edoc) for edoc in pedimento.documentos.filter(acuse_descargado=False)],
|
||||
"edocs": [edoc_to_dict(edoc) for edoc in edocs_batch],
|
||||
"pedimento": pedimento_dict,
|
||||
"credencial": credenciales_dict
|
||||
}
|
||||
|
||||
# Un ciclo de orquestación = un intento
|
||||
pendientes.update(acuse_intentos=F('acuse_intentos') + 1, ultimo_intento_at=timezone.now())
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/all/acuse/pedimento/",
|
||||
@@ -486,20 +525,29 @@ def procesar_edocs(organizacion_id):
|
||||
).distinct()
|
||||
|
||||
for pedimento in pedimentos:
|
||||
if pedimento.documentos.filter(edocument_descargado=False).exists(): # Tipo 3: Remesa
|
||||
# Compuerta del automático (T2026-05-027): solo 'pendiente' con intentos disponibles
|
||||
pendientes = pedimento.documentos.filter(
|
||||
edocument_estado=EstadoDescarga.PENDIENTE,
|
||||
edocument_intentos__lt=MAX_INTENTOS_AUTO,
|
||||
)
|
||||
edocs_batch = list(pendientes)
|
||||
if edocs_batch:
|
||||
|
||||
# Convertir el pedimento a JSON usando el serializer
|
||||
pedimento_dict = pedimento_to_dict(pedimento)
|
||||
credenciales = Vucem.objects.filter(id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id).first()
|
||||
|
||||
|
||||
credenciales_dict = credenciales_to_dict(credenciales)
|
||||
|
||||
|
||||
payload = {
|
||||
"edocs": [edoc_to_dict(edoc) for edoc in pedimento.documentos.filter(edocument_descargado=False)],
|
||||
"edocs": [edoc_to_dict(edoc) for edoc in edocs_batch],
|
||||
"pedimento": pedimento_dict,
|
||||
"credencial": credenciales_dict
|
||||
}
|
||||
|
||||
# Un ciclo de orquestación = un intento
|
||||
pendientes.update(edocument_intentos=F('edocument_intentos') + 1, ultimo_intento_at=timezone.now())
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/download/all/edocs/",
|
||||
@@ -660,3 +708,59 @@ def process_all_organizations():
|
||||
)
|
||||
return f"Dispatched {active_orgs.count()} organizations"
|
||||
|
||||
@shared_task
|
||||
def reintentar_descargas_pendientes():
|
||||
"""
|
||||
Reintento recurrente de descargas VUCEM (T2026-05-027): transiciona a 'error'
|
||||
los registros que agotaron MAX_INTENTOS_AUTO y relanza los pendientes por
|
||||
organización. El incremento del contador vive en las tareas procesar_*
|
||||
(puerta común de todos los flujos automáticos), por lo que aquí solo se orquesta.
|
||||
"""
|
||||
ahora = timezone.now()
|
||||
mensaje_tope = (
|
||||
f"Se agotaron {MAX_INTENTOS_AUTO} intentos automáticos de descarga; "
|
||||
f"requiere reproceso manual"
|
||||
)
|
||||
|
||||
# 1) Transicionar a 'error' lo que agotó el tope automático.
|
||||
# update() no pasa por save(): sincronizar también el booleano legado y updated_at.
|
||||
edocs_err = EDocument.objects.filter(
|
||||
edocument_estado=EstadoDescarga.PENDIENTE,
|
||||
edocument_intentos__gte=MAX_INTENTOS_AUTO,
|
||||
).update(edocument_estado=EstadoDescarga.ERROR, edocument_descargado=False,
|
||||
ultimo_error=mensaje_tope, updated_at=ahora)
|
||||
acuses_err = EDocument.objects.filter(
|
||||
acuse_estado=EstadoDescarga.PENDIENTE,
|
||||
acuse_intentos__gte=MAX_INTENTOS_AUTO,
|
||||
).update(acuse_estado=EstadoDescarga.ERROR, acuse_descargado=False,
|
||||
ultimo_error=mensaje_tope, updated_at=ahora)
|
||||
coves_err = Cove.objects.filter(
|
||||
cove_estado=EstadoDescarga.PENDIENTE,
|
||||
cove_intentos__gte=MAX_INTENTOS_AUTO,
|
||||
).update(cove_estado=EstadoDescarga.ERROR, cove_descargado=False,
|
||||
ultimo_error=mensaje_tope, updated_at=ahora)
|
||||
acuse_coves_err = Cove.objects.filter(
|
||||
acuse_cove_estado=EstadoDescarga.PENDIENTE,
|
||||
acuse_cove_intentos__gte=MAX_INTENTOS_AUTO,
|
||||
).update(acuse_cove_estado=EstadoDescarga.ERROR, acuse_cove_descargado=False,
|
||||
ultimo_error=mensaje_tope, updated_at=ahora)
|
||||
|
||||
if edocs_err or acuses_err or coves_err or acuse_coves_err:
|
||||
logger.info(
|
||||
f"Tope de intentos agotado -> error: edocs={edocs_err}, acuses={acuses_err}, "
|
||||
f"coves={coves_err}, acuse_coves={acuse_coves_err}"
|
||||
)
|
||||
|
||||
# 2) Relanzar por organización (procesar_* aplica la compuerta e incrementa el contador)
|
||||
active_orgs = Organizacion.objects.filter(
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
apply_auto_download=True,
|
||||
)
|
||||
for org in active_orgs:
|
||||
process_organization_batch.apply_async(
|
||||
args=[str(org.id)],
|
||||
queue='org_processing'
|
||||
)
|
||||
return f"Reintentos despachados para {active_orgs.count()} organizaciones"
|
||||
|
||||
|
||||
@@ -224,3 +224,275 @@ class BulkCreateDocumentReplaceTests(APITestCase):
|
||||
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_207_MULTI_STATUS, status.HTTP_201_CREATED])
|
||||
data = response.json()
|
||||
self.assertGreaterEqual(data.get("already_existing_count", 0), 1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests del comando fix_partidas_error
|
||||
# Una partida descargado=True solo es válida si alguno de sus documentos
|
||||
# contiene consultarPartidaRespuesta sin tieneError=true. Partidas que solo
|
||||
# tienen el REQUEST (o errores) deben volver a descargado=False.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from io import StringIO
|
||||
from types import SimpleNamespace
|
||||
from django.core.management import call_command
|
||||
from django.test import TestCase
|
||||
|
||||
|
||||
XML_RESPUESTA_VALIDA = (
|
||||
"<?xml version='1.0' encoding='UTF-8'?>"
|
||||
'<S:Envelope xmlns:S="http://schemas.xmlsoap.org/soap/envelope/"><S:Body>'
|
||||
'<ns9:consultarPartidaRespuesta xmlns:ns9="http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/consultarpartida">'
|
||||
"<tieneError>false</tieneError><ns9:partida/></ns9:consultarPartidaRespuesta>"
|
||||
"</S:Body></S:Envelope>"
|
||||
)
|
||||
|
||||
XML_ERROR_VUCEM = (
|
||||
"<?xml version='1.0' encoding='UTF-8'?>"
|
||||
'<S:Envelope xmlns:S="http://schemas.xmlsoap.org/soap/envelope/"><S:Body>'
|
||||
'<ns9:consultarPartidaRespuesta xmlns:ns9="http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/consultarpartida">'
|
||||
"<tieneError>true</tieneError></ns9:consultarPartidaRespuesta>"
|
||||
"</S:Body></S:Envelope>"
|
||||
)
|
||||
|
||||
XML_ECO_REQUEST = (
|
||||
"<?xml version='1.0' encoding='UTF-8'?>"
|
||||
'<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"'
|
||||
' xmlns:con="http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/consultarpartida"><soapenv:Body>'
|
||||
"<con:consultarPartidaPeticion><con:peticion/></con:consultarPartidaPeticion>"
|
||||
"</soapenv:Body></soapenv:Envelope>"
|
||||
)
|
||||
|
||||
|
||||
class _FakeMinioObject:
|
||||
"""Simula el objeto retornado por minio get_object."""
|
||||
|
||||
def __init__(self, content):
|
||||
self._content = content
|
||||
|
||||
def read(self):
|
||||
return self._content
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
def release_conn(self):
|
||||
pass
|
||||
|
||||
|
||||
class FixPartidasErrorCommandTests(TestCase):
|
||||
PED_APP = "24-01-3420-1234567"
|
||||
|
||||
def setUp(self):
|
||||
from api.customs.models import Partida
|
||||
from api.record.models import DocumentType
|
||||
|
||||
self.licencia = Licencia.objects.create(nombre="LicFixPartidas", almacenamiento=100)
|
||||
self.org = Organizacion.objects.create(
|
||||
nombre="OrgFixPartidas", licencia=self.licencia, is_active=True, is_verified=True
|
||||
)
|
||||
# Pedimento VÁLIDO (no malformado): el comando ya no se limita a malformados
|
||||
self.pedimento = Pedimento.objects.create(
|
||||
organizacion=self.org,
|
||||
pedimento="1234567",
|
||||
pedimento_app=self.PED_APP,
|
||||
aduana="034",
|
||||
patente="3420",
|
||||
numero_operacion="12345678",
|
||||
)
|
||||
self.partida = Partida.objects.create(
|
||||
pedimento=self.pedimento,
|
||||
organizacion=self.org,
|
||||
numero_partida=1,
|
||||
descargado=True,
|
||||
)
|
||||
self.type_resp = DocumentType.objects.get_or_create(id=1, defaults={"nombre": "XML"})[0]
|
||||
self.type_req = DocumentType.objects.get_or_create(id=17, defaults={"nombre": "PT Request"})[0]
|
||||
self.type_err = DocumentType.objects.get_or_create(id=18, defaults={"nombre": "PT Error"})[0]
|
||||
|
||||
# Storage simulado: dict path -> bytes
|
||||
self.storage = {}
|
||||
patcher = patch("api.customs.management.commands.fix_partidas_error.minio_client")
|
||||
self.minio = patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
self.minio._bucket_name = "test-bucket"
|
||||
self.minio.file_exists.side_effect = lambda name: name in self.storage
|
||||
self.minio._client.get_object.side_effect = (
|
||||
lambda bucket, name: _FakeMinioObject(self.storage[name])
|
||||
)
|
||||
self.minio.upload_file.side_effect = (
|
||||
lambda name, file_data=None, content_type=None: self.storage.__setitem__(
|
||||
name, file_data.read()
|
||||
)
|
||||
)
|
||||
self.minio.delete_file.side_effect = lambda name: self.storage.pop(name, None)
|
||||
|
||||
def _doc(self, filename, doc_type, content=None):
|
||||
from api.record.models import Document
|
||||
|
||||
path = f"org/{self.PED_APP}/{filename}"
|
||||
doc = Document.objects.create(
|
||||
organizacion=self.org,
|
||||
pedimento=self.pedimento,
|
||||
document_type=doc_type,
|
||||
archivo=path,
|
||||
size=100,
|
||||
extension="xml",
|
||||
)
|
||||
if content is not None:
|
||||
self.storage[path] = content.encode("utf-8")
|
||||
return doc
|
||||
|
||||
def _run(self, **kwargs):
|
||||
out = StringIO()
|
||||
call_command("fix_partidas_error", stdout=out, stderr=StringIO(), **kwargs)
|
||||
return out.getvalue()
|
||||
|
||||
def test_partida_solo_request_se_marca_no_descargada(self):
|
||||
"""El caso reportado: descargado=True pero solo existe el XML del REQUEST."""
|
||||
self._doc(f"vu_PT_{self.PED_APP}_1_REQUEST.xml", self.type_req, XML_ECO_REQUEST)
|
||||
|
||||
self._run(pedimento=str(self.pedimento.id))
|
||||
|
||||
self.partida.refresh_from_db()
|
||||
self.assertFalse(self.partida.descargado)
|
||||
|
||||
def test_partida_sin_documentos_se_marca_no_descargada(self):
|
||||
"""descargado=True sin ningún documento tampoco es una descarga real."""
|
||||
self._run(pedimento=str(self.pedimento.id))
|
||||
|
||||
self.partida.refresh_from_db()
|
||||
self.assertFalse(self.partida.descargado)
|
||||
|
||||
def test_partida_con_respuesta_valida_permanece_descargada(self):
|
||||
"""Con consultarPartidaRespuesta sin error la partida no se toca."""
|
||||
self._doc(f"vu_PT_{self.PED_APP}_1_REQUEST.xml", self.type_req, XML_ECO_REQUEST)
|
||||
self._doc(f"vu_PT_{self.PED_APP}_1.xml", self.type_resp, XML_RESPUESTA_VALIDA)
|
||||
|
||||
self._run(pedimento=str(self.pedimento.id))
|
||||
|
||||
self.partida.refresh_from_db()
|
||||
self.assertTrue(self.partida.descargado)
|
||||
|
||||
def test_doc_con_error_vucem_se_renombra_y_marca_no_descargada(self):
|
||||
"""tieneError=true: doc → type 18 con sufijo _ERROR y partida → False."""
|
||||
doc = self._doc(f"vu_PT_{self.PED_APP}_1.xml", self.type_resp, XML_ERROR_VUCEM)
|
||||
old_path = doc.archivo.name
|
||||
|
||||
self._run(pedimento=str(self.pedimento.id))
|
||||
|
||||
self.partida.refresh_from_db()
|
||||
doc.refresh_from_db()
|
||||
self.assertFalse(self.partida.descargado)
|
||||
self.assertEqual(doc.document_type_id, 18)
|
||||
self.assertTrue(doc.archivo.name.endswith(f"vu_PT_{self.PED_APP}_1_ERROR.xml"))
|
||||
self.assertTrue(doc.vu)
|
||||
self.assertNotIn(old_path, self.storage)
|
||||
self.assertIn(doc.archivo.name, self.storage)
|
||||
|
||||
def test_eco_de_request_guardado_como_respuesta_se_reclasifica(self):
|
||||
"""Un eco de consultarPartidaPeticion guardado como respuesta se
|
||||
reclasifica a type 17 sin chocar con el REQUEST real existente."""
|
||||
self._doc(f"vu_PT_{self.PED_APP}_1_REQUEST.xml", self.type_req, XML_ECO_REQUEST)
|
||||
doc = self._doc(f"vu_PT_{self.PED_APP}_1.xml", self.type_resp, XML_ECO_REQUEST)
|
||||
|
||||
self._run(pedimento=str(self.pedimento.id))
|
||||
|
||||
self.partida.refresh_from_db()
|
||||
doc.refresh_from_db()
|
||||
self.assertFalse(self.partida.descargado)
|
||||
self.assertEqual(doc.document_type_id, 17)
|
||||
# El nombre sin índice ya lo usa el REQUEST real → debe ir con _1
|
||||
self.assertTrue(doc.archivo.name.endswith(f"vu_PT_{self.PED_APP}_1_REQUEST_1.xml"))
|
||||
|
||||
def test_doc_ausente_sin_canario_no_cambia_partida(self):
|
||||
"""Archivo ausente y NINGÚN archivo del pedimento en storage: posible
|
||||
storage equivocado (p. ej. dev) → sin cambios."""
|
||||
self._doc(f"vu_PT_{self.PED_APP}_1.xml", self.type_resp, content=None)
|
||||
|
||||
self._run(pedimento=str(self.pedimento.id))
|
||||
|
||||
self.partida.refresh_from_db()
|
||||
self.assertTrue(self.partida.descargado)
|
||||
|
||||
def test_registro_fantasma_con_storage_real_se_marca_no_descargada(self):
|
||||
"""Document type 1 en BD sin archivo en storage, pero el REQUEST sí
|
||||
existe físicamente (canario): el storage es el correcto, el registro es
|
||||
fantasma → la partida no tiene XML de partida → descargado=False."""
|
||||
self._doc(f"vu_PT_{self.PED_APP}_1_REQUEST.xml", self.type_req, XML_ECO_REQUEST)
|
||||
fantasma = self._doc(f"vu_PT_{self.PED_APP}_1.xml", self.type_resp, content=None)
|
||||
|
||||
self._run(pedimento=str(self.pedimento.id))
|
||||
|
||||
self.partida.refresh_from_db()
|
||||
fantasma.refresh_from_db()
|
||||
self.assertFalse(self.partida.descargado)
|
||||
# El registro fantasma se reporta pero no se modifica ni se borra
|
||||
self.assertEqual(fantasma.document_type_id, 1)
|
||||
|
||||
def test_storage_inaccesible_no_cambia_partida(self):
|
||||
"""Excepción al consultar storage (conexión caída): sin cambios."""
|
||||
self._doc(f"vu_PT_{self.PED_APP}_1.xml", self.type_resp, XML_ERROR_VUCEM)
|
||||
self.minio.file_exists.side_effect = Exception("connection refused")
|
||||
|
||||
self._run(pedimento=str(self.pedimento.id))
|
||||
|
||||
self.partida.refresh_from_db()
|
||||
self.assertTrue(self.partida.descargado)
|
||||
|
||||
def test_naming_legacy_valida_partida(self):
|
||||
"""Documentos con nomenclatura legacy (partida al final) también validan."""
|
||||
self._doc("vu_PT_010Imp_034_3420_1234567_1.xml", self.type_resp, XML_RESPUESTA_VALIDA)
|
||||
|
||||
self._run(pedimento=str(self.pedimento.id))
|
||||
|
||||
self.partida.refresh_from_db()
|
||||
self.assertTrue(self.partida.descargado)
|
||||
|
||||
def test_dry_run_no_modifica(self):
|
||||
"""--dry-run reporta pero no toca BD ni storage."""
|
||||
doc = self._doc(f"vu_PT_{self.PED_APP}_1.xml", self.type_resp, XML_ERROR_VUCEM)
|
||||
|
||||
self._run(pedimento=str(self.pedimento.id), dry_run=True)
|
||||
|
||||
self.partida.refresh_from_db()
|
||||
doc.refresh_from_db()
|
||||
self.assertTrue(self.partida.descargado)
|
||||
self.assertEqual(doc.document_type_id, 1)
|
||||
self.assertIn(doc.archivo.name, self.storage)
|
||||
|
||||
def test_universo_general_incluye_pedimentos_validos(self):
|
||||
"""Sin --pedimento ni --solo-malformados también procesa pedimentos bien formados."""
|
||||
self._doc(f"vu_PT_{self.PED_APP}_1_REQUEST.xml", self.type_req, XML_ECO_REQUEST)
|
||||
|
||||
self._run()
|
||||
|
||||
self.partida.refresh_from_db()
|
||||
self.assertFalse(self.partida.descargado)
|
||||
|
||||
def test_solo_malformados_excluye_pedimentos_validos(self):
|
||||
"""Con --solo-malformados un pedimento bien formado no se procesa."""
|
||||
self._doc(f"vu_PT_{self.PED_APP}_1_REQUEST.xml", self.type_req, XML_ECO_REQUEST)
|
||||
|
||||
self._run(solo_malformados=True)
|
||||
|
||||
self.partida.refresh_from_db()
|
||||
self.assertTrue(self.partida.descargado)
|
||||
|
||||
def test_no_confunde_partida_1_con_11(self):
|
||||
"""La asignación por nombre no debe mezclar partida 1 con partida 11."""
|
||||
from api.customs.management.commands.fix_partidas_error import Command
|
||||
|
||||
docs = [
|
||||
SimpleNamespace(id=1, document_type_id=1, archivo=SimpleNamespace(name=f"org/x/vu_PT_{self.PED_APP}_1.xml")),
|
||||
SimpleNamespace(id=2, document_type_id=17, archivo=SimpleNamespace(name=f"org/x/vu_PT_{self.PED_APP}_1_REQUEST.xml")),
|
||||
SimpleNamespace(id=3, document_type_id=1, archivo=SimpleNamespace(name=f"org/x/vu_PT_{self.PED_APP}_11.xml")),
|
||||
SimpleNamespace(id=4, document_type_id=17, archivo=SimpleNamespace(name=f"org/x/vu_PT_{self.PED_APP}_11_REQUEST.xml")),
|
||||
]
|
||||
cmd = Command()
|
||||
|
||||
ids_p1 = {d.id for d in cmd._docs_de_partida(docs, self.PED_APP, 1)}
|
||||
ids_p11 = {d.id for d in cmd._docs_de_partida(docs, self.PED_APP, 11)}
|
||||
|
||||
self.assertEqual(ids_p1, {1, 2})
|
||||
self.assertEqual(ids_p11, {3, 4})
|
||||
|
||||
@@ -23,6 +23,7 @@ from core.permissions import (
|
||||
get_org_context,
|
||||
require_permission,
|
||||
user_has_permission,
|
||||
user_has_role,
|
||||
is_internal_service_request,
|
||||
)
|
||||
from api.customs.models import (
|
||||
@@ -33,6 +34,7 @@ from api.customs.models import (
|
||||
Cove,
|
||||
Importador,
|
||||
Partida,
|
||||
EstadoDescarga,
|
||||
)
|
||||
from api.customs.serializers import (
|
||||
PedimentoSerializer,
|
||||
@@ -2338,9 +2340,19 @@ class PartidaViewSet(viewsets.ModelViewSet):
|
||||
if not org:
|
||||
return Partida.objects.none()
|
||||
qs = Partida.objects.filter(pedimento__organizacion=org)
|
||||
# Misma precedencia que los mixins de filtrado: superuser y roles
|
||||
# operativos ven todo lo de su org; is_importador no los degrada.
|
||||
if (
|
||||
user.is_superuser or
|
||||
user_has_role(user, 'admin') or
|
||||
user_has_role(user, 'developer') or
|
||||
user_has_role(user, 'Agente Aduanal') or
|
||||
user_has_role(user, 'user')
|
||||
):
|
||||
return qs
|
||||
if user.is_importador:
|
||||
qs = qs.filter(pedimento__contribuyente__in=user.rfc.all())
|
||||
return qs
|
||||
return qs.filter(pedimento__contribuyente__in=user.rfc.all())
|
||||
return Partida.objects.none()
|
||||
|
||||
def perform_create(self, serializer):
|
||||
if is_internal_service_request(self.request):
|
||||
@@ -2456,12 +2468,20 @@ class ViewSetProcesamientoPedimento(viewsets.ModelViewSet, ProcesosPorOrganizaci
|
||||
org = get_org_context(user)
|
||||
if not org:
|
||||
return ProcesamientoPedimento.objects.none()
|
||||
qs = ProcesamientoPedimento.objects.filter(organizacion=org)
|
||||
# Misma precedencia que los mixins de filtrado: superuser y roles
|
||||
# operativos ven todo lo de su org; is_importador no los degrada.
|
||||
if (
|
||||
user.is_superuser or
|
||||
user_has_role(user, 'admin') or
|
||||
user_has_role(user, 'developer') or
|
||||
user_has_role(user, 'Agente Aduanal') or
|
||||
user_has_role(user, 'user')
|
||||
):
|
||||
return qs
|
||||
if user.is_importador:
|
||||
return ProcesamientoPedimento.objects.filter(
|
||||
organizacion=org,
|
||||
pedimento__contribuyente__in=user.rfc.all()
|
||||
)
|
||||
return ProcesamientoPedimento.objects.filter(organizacion=org)
|
||||
return qs.filter(pedimento__contribuyente__in=user.rfc.all())
|
||||
return ProcesamientoPedimento.objects.none()
|
||||
|
||||
def perform_create(self, serializer):
|
||||
if is_internal_service_request(self.request):
|
||||
@@ -2485,6 +2505,53 @@ class ViewSetProcesamientoPedimento(viewsets.ModelViewSet, ProcesosPorOrganizaci
|
||||
|
||||
my_tags = ['Procesamientos_Pedimentos']
|
||||
|
||||
def _crear_documento_error_vu(registro, numero, doc_type_error_id, mensaje, file_prefix='error_vu'):
|
||||
"""
|
||||
Crea un Document de error VU (tipos 20/22/24/26) para dejar evidencia en la
|
||||
pestaña Errores VU cuando se detecta una inconsistencia de descarga.
|
||||
`registro` debe tener pedimento y organizacion. El nombre del archivo incluye
|
||||
el número del registro para que el frontend lo asocie (archivo__icontains).
|
||||
"""
|
||||
import logging
|
||||
logger = logging.getLogger('api.customs.views')
|
||||
|
||||
doc_type_error = DocumentType.objects.filter(id=doc_type_error_id).first()
|
||||
if not doc_type_error:
|
||||
return
|
||||
|
||||
error_content = mensaje.encode('utf-8')
|
||||
tmp_path = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(mode='wb', suffix='.txt', delete=False) as f:
|
||||
f.write(error_content)
|
||||
tmp_path = f.name
|
||||
|
||||
pedimento_app = getattr(registro.pedimento, 'pedimento_app', str(registro.pedimento.pedimento))
|
||||
file_name = f"{file_prefix}_{numero}.txt"
|
||||
|
||||
saved_path = storage_service.save_document_from_path(
|
||||
file_path=tmp_path,
|
||||
file_name=file_name,
|
||||
organizacion_id=registro.organizacion_id,
|
||||
pedimento_app=pedimento_app
|
||||
)
|
||||
|
||||
if saved_path:
|
||||
Document.objects.create(
|
||||
organizacion=registro.organizacion,
|
||||
pedimento=registro.pedimento,
|
||||
archivo=saved_path,
|
||||
document_type=doc_type_error,
|
||||
extension='TXT',
|
||||
size=len(error_content),
|
||||
fuente=None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error creando documento de error VU para {numero}: {e}")
|
||||
finally:
|
||||
if tmp_path and os.path.exists(tmp_path):
|
||||
os.unlink(tmp_path)
|
||||
|
||||
class ViewSetEDocument(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltradaMixin):
|
||||
"""
|
||||
ViewSet for EDocument model.
|
||||
@@ -2492,7 +2559,18 @@ class ViewSetEDocument(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
|
||||
serializer_class = EDocumentSerializer
|
||||
pagination_class = CustomPagination
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||
filterset_fields = ['pedimento', 'numero_edocument', 'organizacion']
|
||||
filterset_fields = {
|
||||
'pedimento': ['exact'],
|
||||
'numero_edocument': ['exact', 'icontains'],
|
||||
'organizacion': ['exact'],
|
||||
'clave': ['exact', 'icontains'],
|
||||
'descripcion': ['icontains'],
|
||||
'edocument_descargado': ['exact'],
|
||||
'acuse_descargado': ['exact'],
|
||||
'edocument_estado': ['exact'],
|
||||
'acuse_estado': ['exact'],
|
||||
'created_at': ['gte', 'lte'],
|
||||
}
|
||||
search_fields = ['numero_edocument', 'descripcion', 'organizacion']
|
||||
ordering_fields = ['created_at', 'updated_at', 'numero_edocument']
|
||||
ordering = ['-created_at']
|
||||
@@ -2510,6 +2588,7 @@ class ViewSetEDocument(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
|
||||
'destroy': 'edocuments.delete',
|
||||
'bulk_delete_edocs_vu': 'edocuments.delete',
|
||||
'reset_acuse': 'edocuments.edit',
|
||||
'reset_edocument': 'edocuments.edit',
|
||||
}
|
||||
codename = perms.get(self.action, 'edocuments.view')
|
||||
return [IsAuthenticated(), require_permission(codename)()]
|
||||
@@ -2539,28 +2618,30 @@ class ViewSetEDocument(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
|
||||
@action(detail=True, methods=['post'], url_path='reset-acuse')
|
||||
def reset_acuse(self, request, pk=None):
|
||||
"""
|
||||
Detecta inconsistencia cuando acuse_descargado=True pero no existe el documento
|
||||
de acuse (tipo 4). Crea un registro de error tipo 26 para Errores VU y
|
||||
restablece acuse_descargado=False para permitir reintentar.
|
||||
Detecta inconsistencia cuando el acuse está marcado como descargado pero el
|
||||
documento de acuse (tipo 4) no existe en BD o el archivo falta en storage.
|
||||
Crea un registro de error tipo 26 para Errores VU y restablece
|
||||
acuse_estado='pendiente' con contador de intentos en 0 — única vía que
|
||||
re-habilita el reintento automático (T2026-05-027).
|
||||
"""
|
||||
from api.record.models import Document, DocumentType
|
||||
import logging
|
||||
logger = logging.getLogger('api.customs.views')
|
||||
|
||||
edoc = self.get_object()
|
||||
|
||||
if not edoc.acuse_descargado:
|
||||
if edoc.acuse_estado != EstadoDescarga.DESCARGADO:
|
||||
return Response(
|
||||
{"error": "El acuse no está marcado como descargado"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Verificar si el acuse PDF (tipo 4 = Pedimento Acuse) existe realmente
|
||||
acuse_disponible = Document.objects.filter(
|
||||
# Verificar el acuse (tipo 4 = Pedimento Acuse) en BD y físicamente en storage
|
||||
acuse_docs = Document.objects.filter(
|
||||
pedimento=edoc.pedimento,
|
||||
archivo__icontains=edoc.numero_edocument,
|
||||
document_type_id=4
|
||||
).exists()
|
||||
)
|
||||
acuse_disponible = any(
|
||||
doc.size and storage_service.file_exists(doc.archivo.name)
|
||||
for doc in acuse_docs
|
||||
)
|
||||
|
||||
if acuse_disponible:
|
||||
return Response(
|
||||
@@ -2568,51 +2649,74 @@ class ViewSetEDocument(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
|
||||
status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
# Inconsistencia confirmada: crear documento de error tipo 26 para Errores VU
|
||||
doc_type_error = DocumentType.objects.filter(id=26).first()
|
||||
if doc_type_error:
|
||||
error_content = (
|
||||
# Inconsistencia confirmada: dejar evidencia en Errores VU (tipo 26)
|
||||
_crear_documento_error_vu(
|
||||
registro=edoc,
|
||||
numero=edoc.numero_edocument,
|
||||
doc_type_error_id=26,
|
||||
mensaje=(
|
||||
f"Inconsistencia detectada: el acuse del EDocument {edoc.numero_edocument} "
|
||||
f"fue marcado como descargado pero el documento no se encuentra disponible. "
|
||||
f"El estado fue restablecido para permitir reprocesamiento."
|
||||
).encode('utf-8')
|
||||
),
|
||||
file_prefix='error_acuse',
|
||||
)
|
||||
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode='wb', suffix='.txt', delete=False
|
||||
) as f:
|
||||
f.write(error_content)
|
||||
tmp_path = f.name
|
||||
edoc.acuse_estado = EstadoDescarga.PENDIENTE
|
||||
edoc.acuse_intentos = 0
|
||||
edoc.ultimo_error = None
|
||||
edoc.save()
|
||||
|
||||
pedimento_app = getattr(edoc.pedimento, 'pedimento_app', str(edoc.pedimento.pedimento))
|
||||
file_name = f"error_acuse_{edoc.numero_edocument}.txt"
|
||||
serializer = self.get_serializer(edoc)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
saved_path = storage_service.save_document_from_path(
|
||||
file_path=tmp_path,
|
||||
file_name=file_name,
|
||||
organizacion_id=edoc.organizacion_id,
|
||||
pedimento_app=pedimento_app
|
||||
)
|
||||
@action(detail=True, methods=['post'], url_path='reset-edocument')
|
||||
def reset_edocument(self, request, pk=None):
|
||||
"""
|
||||
Igual que reset-acuse pero para el documento general del EDocument: si está
|
||||
marcado como descargado sin documento disponible (BD o storage), crea error
|
||||
tipo 22 y restablece edocument_estado='pendiente' con contador en 0.
|
||||
"""
|
||||
edoc = self.get_object()
|
||||
|
||||
if saved_path:
|
||||
Document.objects.create(
|
||||
organizacion=edoc.organizacion,
|
||||
pedimento=edoc.pedimento,
|
||||
archivo=saved_path,
|
||||
document_type=doc_type_error,
|
||||
extension='TXT',
|
||||
size=len(error_content),
|
||||
fuente=None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error creando documento de error para acuse {edoc.numero_edocument}: {e}"
|
||||
)
|
||||
finally:
|
||||
if os.path.exists(tmp_path):
|
||||
os.unlink(tmp_path)
|
||||
if edoc.edocument_estado != EstadoDescarga.DESCARGADO:
|
||||
return Response(
|
||||
{"error": "El e-documento no está marcado como descargado"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
edoc.acuse_descargado = False
|
||||
# Documentos generales del EDocument: se excluyen acuse (4), requests (21, 25)
|
||||
# y errores (22, 26) del catálogo document_type
|
||||
edoc_docs = Document.objects.filter(
|
||||
pedimento=edoc.pedimento,
|
||||
archivo__icontains=edoc.numero_edocument,
|
||||
).exclude(document_type_id__in=[4, 21, 22, 25, 26])
|
||||
edoc_disponible = any(
|
||||
doc.size and storage_service.file_exists(doc.archivo.name)
|
||||
for doc in edoc_docs
|
||||
)
|
||||
|
||||
if edoc_disponible:
|
||||
return Response(
|
||||
{"status": "El e-documento está disponible correctamente", "edocument_disponible": True},
|
||||
status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
_crear_documento_error_vu(
|
||||
registro=edoc,
|
||||
numero=edoc.numero_edocument,
|
||||
doc_type_error_id=22,
|
||||
mensaje=(
|
||||
f"Inconsistencia detectada: el EDocument {edoc.numero_edocument} fue marcado "
|
||||
f"como descargado pero el documento no se encuentra disponible. "
|
||||
f"El estado fue restablecido para permitir reprocesamiento."
|
||||
),
|
||||
file_prefix='error_edocument',
|
||||
)
|
||||
|
||||
edoc.edocument_estado = EstadoDescarga.PENDIENTE
|
||||
edoc.edocument_intentos = 0
|
||||
edoc.ultimo_error = None
|
||||
edoc.save()
|
||||
|
||||
serializer = self.get_serializer(edoc)
|
||||
@@ -2625,7 +2729,16 @@ class ViewSetCove(viewsets.ModelViewSet, OrganizacionFiltradaMixin):
|
||||
serializer_class = CoveSerializer
|
||||
pagination_class = CustomPagination
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||
filterset_fields = ['pedimento', 'numero_cove', 'organizacion']
|
||||
filterset_fields = {
|
||||
'pedimento': ['exact'],
|
||||
'numero_cove': ['exact', 'icontains'],
|
||||
'organizacion': ['exact'],
|
||||
'cove_descargado': ['exact'],
|
||||
'acuse_cove_descargado': ['exact'],
|
||||
'cove_estado': ['exact'],
|
||||
'acuse_cove_estado': ['exact'],
|
||||
'created_at': ['gte', 'lte'],
|
||||
}
|
||||
search_fields = ['numero_cove', 'descripcion', 'organizacion']
|
||||
ordering_fields = ['created_at', 'updated_at', 'numero_cove']
|
||||
ordering = ['-created_at']
|
||||
@@ -2642,6 +2755,8 @@ class ViewSetCove(viewsets.ModelViewSet, OrganizacionFiltradaMixin):
|
||||
'partial_update': 'coves.edit',
|
||||
'destroy': 'coves.delete',
|
||||
'bulk_delete_coves_vu': 'coves.delete',
|
||||
'reset_cove': 'coves.edit',
|
||||
'reset_acuse_cove': 'coves.edit',
|
||||
}
|
||||
codename = perms.get(self.action, 'coves.view')
|
||||
return [IsAuthenticated(), require_permission(codename)()]
|
||||
@@ -2668,6 +2783,110 @@ class ViewSetCove(viewsets.ModelViewSet, OrganizacionFiltradaMixin):
|
||||
def perform_destroy(self, instance):
|
||||
instance.delete()
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='reset-cove')
|
||||
def reset_cove(self, request, pk=None):
|
||||
"""
|
||||
Detecta inconsistencia cuando la COVE está marcada como descargada pero el
|
||||
documento no existe en BD o el archivo falta en storage. Crea error tipo 20
|
||||
para Errores VU y restablece cove_estado='pendiente' con contador en 0.
|
||||
"""
|
||||
cove = self.get_object()
|
||||
|
||||
if cove.cove_estado != EstadoDescarga.DESCARGADO:
|
||||
return Response(
|
||||
{"error": "La COVE no está marcada como descargada"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Documentos generales de la COVE: se excluyen acuse (7), requests (19, 23)
|
||||
# y errores (20, 24) del catálogo document_type
|
||||
cove_docs = Document.objects.filter(
|
||||
pedimento=cove.pedimento,
|
||||
archivo__icontains=cove.numero_cove,
|
||||
).exclude(document_type_id__in=[7, 19, 20, 23, 24])
|
||||
cove_disponible = any(
|
||||
doc.size and storage_service.file_exists(doc.archivo.name)
|
||||
for doc in cove_docs
|
||||
)
|
||||
|
||||
if cove_disponible:
|
||||
return Response(
|
||||
{"status": "La COVE está disponible correctamente", "cove_disponible": True},
|
||||
status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
_crear_documento_error_vu(
|
||||
registro=cove,
|
||||
numero=cove.numero_cove,
|
||||
doc_type_error_id=20,
|
||||
mensaje=(
|
||||
f"Inconsistencia detectada: la COVE {cove.numero_cove} fue marcada "
|
||||
f"como descargada pero el documento no se encuentra disponible. "
|
||||
f"El estado fue restablecido para permitir reprocesamiento."
|
||||
),
|
||||
file_prefix='error_cove',
|
||||
)
|
||||
|
||||
cove.cove_estado = EstadoDescarga.PENDIENTE
|
||||
cove.cove_intentos = 0
|
||||
cove.ultimo_error = None
|
||||
cove.save()
|
||||
|
||||
serializer = self.get_serializer(cove)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='reset-acuse-cove')
|
||||
def reset_acuse_cove(self, request, pk=None):
|
||||
"""
|
||||
Detecta inconsistencia cuando el acuse de la COVE (tipo 7) está marcado como
|
||||
descargado pero no existe en BD o el archivo falta en storage. Crea error
|
||||
tipo 24 para Errores VU y restablece acuse_cove_estado='pendiente' con
|
||||
contador en 0.
|
||||
"""
|
||||
cove = self.get_object()
|
||||
|
||||
if cove.acuse_cove_estado != EstadoDescarga.DESCARGADO:
|
||||
return Response(
|
||||
{"error": "El acuse de la COVE no está marcado como descargado"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
acuse_docs = Document.objects.filter(
|
||||
pedimento=cove.pedimento,
|
||||
archivo__icontains=cove.numero_cove,
|
||||
document_type_id=7
|
||||
)
|
||||
acuse_disponible = any(
|
||||
doc.size and storage_service.file_exists(doc.archivo.name)
|
||||
for doc in acuse_docs
|
||||
)
|
||||
|
||||
if acuse_disponible:
|
||||
return Response(
|
||||
{"status": "El acuse está disponible correctamente", "acuse_disponible": True},
|
||||
status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
_crear_documento_error_vu(
|
||||
registro=cove,
|
||||
numero=cove.numero_cove,
|
||||
doc_type_error_id=24,
|
||||
mensaje=(
|
||||
f"Inconsistencia detectada: el acuse de la COVE {cove.numero_cove} "
|
||||
f"fue marcado como descargado pero el documento no se encuentra disponible. "
|
||||
f"El estado fue restablecido para permitir reprocesamiento."
|
||||
),
|
||||
file_prefix='error_acuse_cove',
|
||||
)
|
||||
|
||||
cove.acuse_cove_estado = EstadoDescarga.PENDIENTE
|
||||
cove.acuse_cove_intentos = 0
|
||||
cove.ultimo_error = None
|
||||
cove.save()
|
||||
|
||||
serializer = self.get_serializer(cove)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
class ImportadorViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for Importador model.
|
||||
|
||||
@@ -2510,7 +2510,7 @@ def auditar_integridad_remesa_endpoint(request):
|
||||
|
||||
@swagger_auto_schema(
|
||||
method='post',
|
||||
operation_description="Audita integridad de COVEs del XML de remesa para un pedimento específico",
|
||||
operation_description="Audita integridad de COVEs del XML de remesa para un pedimento específico. Deduce si es consolidado desde el identificador PC del pedimento completo; si falta el documento de remesa, dispara la consulta a VUCEM",
|
||||
request_body=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
properties={'pedimento_id': openapi.Schema(type=openapi.TYPE_STRING)},
|
||||
|
||||
Reference in New Issue
Block a user