fix/de los tickets T2026-05-027, T2025-09-004 y T2025-09-056

This commit is contained in:
2026-06-15 11:18:58 -06:00
parent 7644446267
commit 23ed52c78a
29 changed files with 2992 additions and 987 deletions

View File

@@ -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(

View 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"))

View 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)'),
),
]

View File

@@ -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}"

View File

@@ -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

View File

@@ -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)

View File

@@ -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"

View File

@@ -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})

View File

@@ -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.

View File

@@ -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)},