Compare commits
3 Commits
feature/rb
...
feature/pe
| Author | SHA1 | Date | |
|---|---|---|---|
| 709a5dedab | |||
| 94846fec8a | |||
| e378f2d949 |
57
api/cuser/migrations/0005_customuser_rfc_fk_to_m2m.py
Normal file
57
api/cuser/migrations/0005_customuser_rfc_fk_to_m2m.py
Normal file
@@ -0,0 +1,57 @@
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def copiar_rfc_a_m2m(apps, schema_editor):
|
||||
"""Copia el RFC singular (FK) al lado M2M antes de eliminar el FK."""
|
||||
CustomUser = apps.get_model('cuser', 'CustomUser')
|
||||
db_alias = schema_editor.connection.alias
|
||||
for user in CustomUser.objects.using(db_alias).filter(rfc_old__isnull=False):
|
||||
user.rfc.add(user.rfc_old)
|
||||
|
||||
|
||||
def revertir_m2m_a_fk(apps, schema_editor):
|
||||
"""En reversa: toma el primer RFC del M2M y lo pone de vuelta en el FK temporal."""
|
||||
CustomUser = apps.get_model('cuser', 'CustomUser')
|
||||
db_alias = schema_editor.connection.alias
|
||||
for user in CustomUser.objects.using(db_alias).prefetch_related('rfc'):
|
||||
primer_rfc = user.rfc.first()
|
||||
if primer_rfc:
|
||||
user.rfc_old = primer_rfc
|
||||
user.save(update_fields=['rfc_old'])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cuser', '0004_alter_customuser_rfc'),
|
||||
('customs', '0015_partida_updated_at'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# 1. Renombrar el FK actual a rfc_old para preservar los datos
|
||||
migrations.RenameField(
|
||||
model_name='customuser',
|
||||
old_name='rfc',
|
||||
new_name='rfc_old',
|
||||
),
|
||||
# 2. Crear el nuevo campo M2M
|
||||
migrations.AddField(
|
||||
model_name='customuser',
|
||||
name='rfc',
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text='RFCs de importadores asociados al usuario',
|
||||
related_name='users',
|
||||
to='customs.importador',
|
||||
),
|
||||
),
|
||||
# 3. Copiar datos del FK al M2M
|
||||
migrations.RunPython(copiar_rfc_a_m2m, revertir_m2m_a_fk),
|
||||
# 4. Eliminar el FK temporal
|
||||
migrations.RemoveField(
|
||||
model_name='customuser',
|
||||
name='rfc_old',
|
||||
),
|
||||
]
|
||||
25
api/cuser/migrations/0006_customuser_active_organization.py
Normal file
25
api/cuser/migrations/0006_customuser_active_organization.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cuser', '0005_customuser_rfc_fk_to_m2m'),
|
||||
('organization', '0003_organizacion_apply_auto_download'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='customuser',
|
||||
name='active_organization',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text='Solo superusuarios: organización activa para contexto de trabajo',
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='superusers_activos',
|
||||
to='organization.organizacion',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -9,7 +9,7 @@ class CustomUserSerializer(serializers.ModelSerializer):
|
||||
Serializer for the CustomUser model.
|
||||
"""
|
||||
|
||||
password = serializers.CharField(write_only=True)
|
||||
password = serializers.CharField(write_only=True, required=False)
|
||||
groups = serializers.PrimaryKeyRelatedField(queryset=Group.objects.all(), many=True, required=False)
|
||||
rfc = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Importador.objects.all(),
|
||||
@@ -23,6 +23,17 @@ class CustomUserSerializer(serializers.ModelSerializer):
|
||||
fields = ['id', 'username', 'email', 'first_name', 'last_name', 'password', 'profile_picture', 'organizacion', 'is_importador', 'rfc', 'is_active', 'is_superuser', 'groups']
|
||||
read_only_fields = ['id', 'organizacion', 'is_superuser']
|
||||
|
||||
def validate_password(self, value):
|
||||
if not value or not value.strip():
|
||||
raise serializers.ValidationError("La contraseña no puede estar vacía o contener solo espacios.")
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
# En create, la contraseña es obligatoria
|
||||
if self.instance is None and not attrs.get('password'):
|
||||
raise serializers.ValidationError({"password": "Este campo es requerido."})
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
groups = validated_data.pop('groups', [])
|
||||
rfcs = validated_data.pop('rfc', [])
|
||||
|
||||
117
api/customs/management/commands/fix_archivo_case.py
Normal file
117
api/customs/management/commands/fix_archivo_case.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
Corrige el mismatch de case entre el campo `archivo` en BD y los nombres
|
||||
reales de los objetos en MinIO.
|
||||
|
||||
Causa habitual: transferencia de archivos de producción a local lowercaseó
|
||||
los filenames, pero la BD conserva los nombres originales con mayúsculas.
|
||||
|
||||
Estrategia: para cada Document cuyo `archivo` no exista en MinIO con el
|
||||
nombre exacto, intenta el filename en minúsculas. Si lo encuentra, actualiza
|
||||
el campo en BD. Los archivos que ya coinciden no se tocan.
|
||||
|
||||
Uso:
|
||||
python manage.py fix_archivo_case --pedimento <UUID> --dry-run
|
||||
python manage.py fix_archivo_case --pedimento <UUID>
|
||||
python manage.py fix_archivo_case --organizacion <UUID> --dry-run
|
||||
python manage.py fix_archivo_case --organizacion <UUID>
|
||||
"""
|
||||
import posixpath
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from api.customs.models import Pedimento
|
||||
from api.record.models import Document
|
||||
from api.utils.minio_client import minio_client
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Corrige mismatch de case entre campo archivo en BD y MinIO."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--pedimento", metavar="UUID",
|
||||
help="UUID del pedimento a corregir.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--organizacion", metavar="UUID",
|
||||
help="UUID de la organización.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run", action="store_true",
|
||||
help="Solo diagnóstico, sin aplicar cambios.",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
ped_id = options.get("pedimento")
|
||||
org_id = options.get("organizacion")
|
||||
dry_run = options["dry_run"]
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(self.style.WARNING(
|
||||
"=== MODO PRUEBA (--dry-run): Sin cambios en BD ===\n"
|
||||
))
|
||||
|
||||
qs = Document.objects.all()
|
||||
if ped_id:
|
||||
try:
|
||||
ped = Pedimento.objects.get(id=ped_id)
|
||||
except Pedimento.DoesNotExist:
|
||||
raise CommandError(f"Pedimento {ped_id!r} no encontrado.")
|
||||
qs = qs.filter(pedimento=ped)
|
||||
self.stdout.write(f"Pedimento: {ped.pedimento_app}\n")
|
||||
elif org_id:
|
||||
qs = qs.filter(organizacion_id=org_id)
|
||||
|
||||
total = qs.count()
|
||||
self.stdout.write(f"Documentos a revisar: {total}\n")
|
||||
|
||||
ok = mismatch = not_found = 0
|
||||
|
||||
for doc in qs.iterator(chunk_size=500):
|
||||
name = doc.archivo.name if doc.archivo else None
|
||||
if not name:
|
||||
continue
|
||||
|
||||
if minio_client.file_exists(name):
|
||||
ok += 1
|
||||
continue
|
||||
|
||||
lower_name = self._lower_filename(name)
|
||||
if lower_name == name:
|
||||
not_found += 1
|
||||
continue
|
||||
|
||||
if minio_client.file_exists(lower_name):
|
||||
mismatch += 1
|
||||
self.stdout.write(
|
||||
f" {'[DRY]' if dry_run else '[FIX]'} doc {doc.id}:\n"
|
||||
f" BD : {name}\n"
|
||||
f" MinIO : {lower_name}\n"
|
||||
)
|
||||
if not dry_run:
|
||||
doc.archivo.name = lower_name
|
||||
doc.save(update_fields=["archivo"])
|
||||
else:
|
||||
not_found += 1
|
||||
|
||||
self.stdout.write(
|
||||
f"\n{'─' * 60}\nRESUMEN\n"
|
||||
f" Coinciden exacto : {ok}\n"
|
||||
f" Mismatch de case : {mismatch}\n"
|
||||
f" No encontrados : {not_found}\n"
|
||||
)
|
||||
|
||||
if dry_run and mismatch:
|
||||
self.stdout.write(self.style.WARNING(
|
||||
"\nEjecuta sin --dry-run para aplicar los cambios."
|
||||
))
|
||||
elif not dry_run and mismatch:
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f"\n{mismatch} registros actualizados en BD."
|
||||
))
|
||||
|
||||
def _lower_filename(self, name):
|
||||
"""Lowercase solo el filename, preserva el path del directorio."""
|
||||
dir_part = posixpath.dirname(name)
|
||||
filename = posixpath.basename(name)
|
||||
return posixpath.join(dir_part, filename.lower())
|
||||
382
api/customs/management/commands/fix_partidas_error.py
Normal file
382
api/customs/management/commands/fix_partidas_error.py
Normal file
@@ -0,0 +1,382 @@
|
||||
"""
|
||||
Diagnóstico y corrección de partidas con descargado=True cuyos documentos
|
||||
de respuesta VUCEM contienen <tieneError>true</tieneError>.
|
||||
|
||||
Convenciones de nomenclatura del microservicio:
|
||||
- REQUEST (type 17): vu_PT_{pedimento_app}_{partida}_REQUEST.xml
|
||||
- ERROR (type 18): vu_PT_{pedimento_app}_{partida}_ERROR.xml
|
||||
- Éxito (type 1): vu_PT_{pedimento_app}_{partida}.xml
|
||||
|
||||
Acciones por cada documento con error VUCEM encontrado:
|
||||
- document_type_id: actual → 18 (PT ERROR)
|
||||
- archivo: renombrado a vu_PT_{pedimento_app}_{partida}_ERROR.xml
|
||||
- Partida.descargado: True → False
|
||||
|
||||
Criterio de pedimento malformado (cualquiera de):
|
||||
- aduana: nulo/vacío o len < 3
|
||||
- numero_operacion: nulo o vacío
|
||||
- patente: nulo/vacío o len < 4
|
||||
- pedimento (campo): nulo/vacío o len < 7
|
||||
|
||||
Uso:
|
||||
python manage.py fix_partidas_error --pedimento <UUID> --dry-run
|
||||
python manage.py fix_partidas_error --organizacion <UUID> --dry-run
|
||||
python manage.py fix_partidas_error --organizacion <UUID>
|
||||
python manage.py fix_partidas_error --dry-run # todas las orgs
|
||||
"""
|
||||
import io
|
||||
import posixpath
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.db.models.functions import Length
|
||||
|
||||
from api.customs.models import Partida, Pedimento
|
||||
from api.record.models import Document
|
||||
from api.utils.minio_client import minio_client
|
||||
|
||||
_PT_REQUEST = 17
|
||||
_PT_ERROR = 18
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Corrección de partidas descargado=True con respuestas de error VUCEM."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--organizacion", metavar="UUID",
|
||||
help="UUID de la organización. Sin este arg: todas las orgs.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pedimento", metavar="UUID",
|
||||
help="UUID del pedimento a diagnosticar/corregir.",
|
||||
)
|
||||
# Filtros de fecha (aplican sobre fecha_pago del pedimento)
|
||||
parser.add_argument(
|
||||
"--fecha-desde", metavar="YYYY-MM-DD",
|
||||
help="Procesar pedimentos con fecha_pago >= esta fecha.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--fecha-hasta", metavar="YYYY-MM-DD",
|
||||
help="Procesar pedimentos con fecha_pago <= esta fecha.",
|
||||
)
|
||||
# Control de lote
|
||||
parser.add_argument(
|
||||
"--offset", type=int, default=0,
|
||||
help="Saltar los primeros N pedimentos malformados (default: 0).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit", type=int, default=0,
|
||||
help="Procesar máximo N pedimentos (default: 0 = todos).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run", action="store_true",
|
||||
help="Solo diagnóstico, sin aplicar cambios.",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Entry point
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def handle(self, *args, **options):
|
||||
org_id = options.get("organizacion")
|
||||
ped_id = options.get("pedimento")
|
||||
fecha_desde = options.get("fecha_desde")
|
||||
fecha_hasta = options.get("fecha_hasta")
|
||||
offset = options["offset"]
|
||||
limit = options["limit"]
|
||||
dry_run = options["dry_run"]
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(self.style.WARNING(
|
||||
"=== MODO PRUEBA (--dry-run): Sin cambios en BD ni storage ===\n"
|
||||
))
|
||||
|
||||
if ped_id:
|
||||
self._handle_single(ped_id, dry_run)
|
||||
return
|
||||
|
||||
ped_qs = self._malformed_qs()
|
||||
|
||||
if org_id:
|
||||
ped_qs = ped_qs.filter(organizacion_id=org_id)
|
||||
if fecha_desde:
|
||||
ped_qs = ped_qs.filter(fecha_pago__gte=fecha_desde)
|
||||
if fecha_hasta:
|
||||
ped_qs = ped_qs.filter(fecha_pago__lte=fecha_hasta)
|
||||
|
||||
ped_qs = ped_qs.select_related("organizacion").order_by("fecha_pago", "pedimento_app")
|
||||
|
||||
total_sin_filtro = ped_qs.count()
|
||||
|
||||
if offset:
|
||||
ped_qs = ped_qs[offset:]
|
||||
if limit:
|
||||
ped_qs = ped_qs[:limit]
|
||||
|
||||
total = ped_qs.count() if not (offset or limit) else min(
|
||||
limit or total_sin_filtro, max(0, total_sin_filtro - offset)
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
f"Pedimentos malformados (total): {total_sin_filtro}\n"
|
||||
f"Procesando este lote : {total}"
|
||||
+ (f" [offset={offset}]" if offset else "")
|
||||
+ (f" [limit={limit}]" if limit else "")
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
if total == 0:
|
||||
self.stdout.write(self.style.SUCCESS("Nada que corregir en este lote."))
|
||||
return
|
||||
|
||||
total_partidas = total_docs = 0
|
||||
for ped in ped_qs:
|
||||
p, d = self._process_pedimento(ped, dry_run)
|
||||
total_partidas += p
|
||||
total_docs += d
|
||||
|
||||
self._print_summary(total, total_partidas, total_docs, dry_run)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Flujo --pedimento
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _handle_single(self, ped_id, dry_run):
|
||||
try:
|
||||
ped = Pedimento.objects.get(id=ped_id)
|
||||
except Pedimento.DoesNotExist:
|
||||
raise CommandError(f"Pedimento {ped_id!r} no encontrado.")
|
||||
|
||||
checks = self._field_checks(ped)
|
||||
self._print_ped_diagnosis(ped, checks)
|
||||
if not any(checks.values()):
|
||||
return
|
||||
self._process_pedimento(ped, dry_run)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Queryset de pedimentos malformados
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _malformed_qs(self):
|
||||
return Pedimento.objects.annotate(
|
||||
aduana_len=Length("aduana"),
|
||||
patente_len=Length("patente"),
|
||||
pedimento_len=Length("pedimento"),
|
||||
).filter(
|
||||
Q(aduana__isnull=True) | Q(aduana="") | Q(aduana_len__lt=3)
|
||||
| Q(numero_operacion__isnull=True) | Q(numero_operacion="")
|
||||
| Q(patente__isnull=True) | Q(patente="") | Q(patente_len__lt=4)
|
||||
| Q(pedimento__isnull=True) | Q(pedimento="") | Q(pedimento_len__lt=7)
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Diagnóstico de un pedimento
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _field_checks(self, ped):
|
||||
return {
|
||||
"aduana (debe tener 3 dígitos)": not ped.aduana or len(ped.aduana.strip()) < 3,
|
||||
"numero_operacion (obligatorio)": not ped.numero_operacion or not ped.numero_operacion.strip(),
|
||||
"patente (debe tener 4 dígitos)": not ped.patente or len(ped.patente.strip()) < 4,
|
||||
"pedimento_fld (debe tener 7 dígitos)": not ped.pedimento or len(ped.pedimento.strip()) < 7,
|
||||
}
|
||||
|
||||
def _print_ped_diagnosis(self, ped, checks):
|
||||
es_malo = any(checks.values())
|
||||
estado = self.style.ERROR("MALFORMADO") if es_malo else self.style.SUCCESS("VÁLIDO")
|
||||
self.stdout.write(
|
||||
f"Pedimento {ped.pedimento_app} (id={ped.id}) → {estado}\n"
|
||||
f" aduana = {ped.aduana!r} (len={len(ped.aduana or '')})\n"
|
||||
f" patente = {ped.patente!r} (len={len(ped.patente or '')})\n"
|
||||
f" numero_op = {ped.numero_operacion!r}\n"
|
||||
f" pedimento_fld = {ped.pedimento!r} (len={len(ped.pedimento or '')})\n"
|
||||
)
|
||||
for campo, malo in checks.items():
|
||||
marca = self.style.ERROR("✗") if malo else self.style.SUCCESS("✓")
|
||||
self.stdout.write(f" {marca} {campo}")
|
||||
self.stdout.write("")
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Procesamiento de un pedimento malformado
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _process_pedimento(self, ped, dry_run):
|
||||
self.stdout.write(
|
||||
f"Pedimento: {ped.pedimento_app} | "
|
||||
f"aduana={ped.aduana!r} patente={ped.patente!r} num_op={ped.numero_operacion!r}"
|
||||
)
|
||||
partidas = Partida.objects.filter(pedimento=ped, descargado=True)
|
||||
n_partidas = partidas.count()
|
||||
|
||||
if n_partidas == 0:
|
||||
self.stdout.write(" → Sin partidas con descargado=True\n")
|
||||
return 0, 0
|
||||
|
||||
self.stdout.write(f" Partidas con descargado=True: {n_partidas}")
|
||||
total_docs_error = 0
|
||||
|
||||
for partida in partidas:
|
||||
# Documentos de respuesta: excluir REQUEST (17) y los ya marcados ERROR (18)
|
||||
patron = f"vu_PT_{ped.pedimento_app}_{partida.numero_partida}_"
|
||||
candidatos = list(
|
||||
Document.objects.filter(
|
||||
pedimento=ped,
|
||||
archivo__icontains=patron,
|
||||
).exclude(document_type_id__in=[_PT_REQUEST, _PT_ERROR])
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
f"\n Partida {partida.numero_partida}: {len(candidatos)} doc(s) candidatos a revisar"
|
||||
)
|
||||
|
||||
docs_con_error = []
|
||||
for doc in candidatos:
|
||||
# estado: "error" | "ok" | "no_verificable"
|
||||
estado, motivo = self._check_vucem_error(doc)
|
||||
if estado == "error":
|
||||
icono = self.style.ERROR("✗ ERROR VUCEM")
|
||||
elif estado == "ok":
|
||||
icono = self.style.SUCCESS("✓ ok")
|
||||
else:
|
||||
icono = self.style.WARNING("⚠ sin archivo en storage")
|
||||
|
||||
self.stdout.write(f" [{icono}] type={doc.document_type_id} | {doc.archivo.name}")
|
||||
|
||||
if estado == "error":
|
||||
self.stdout.write(f" motivo : {motivo}")
|
||||
new_name = self._build_error_filename(
|
||||
doc.archivo.name, ped.pedimento_app, partida.numero_partida, len(docs_con_error)
|
||||
)
|
||||
self.stdout.write(f" → {new_name}")
|
||||
docs_con_error.append(doc)
|
||||
elif estado == "no_verificable":
|
||||
self.stdout.write(f" {motivo} — ejecuta en producción para verificar")
|
||||
|
||||
total_docs_error += len(docs_con_error)
|
||||
|
||||
if not dry_run and docs_con_error:
|
||||
self._apply_fix(partida, docs_con_error, ped.pedimento_app)
|
||||
|
||||
self.stdout.write("")
|
||||
return n_partidas, total_docs_error
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Detección de error VUCEM en el XML
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _check_vucem_error(self, doc):
|
||||
"""
|
||||
Lee el XML desde MinIO y verifica si VUCEM devolvió un error.
|
||||
Retorna ("error" | "ok" | "no_verificable", motivo: str | None).
|
||||
"""
|
||||
try:
|
||||
name = doc.archivo.name
|
||||
if not minio_client.file_exists(name):
|
||||
return "no_verificable", "archivo no encontrado en storage"
|
||||
response = minio_client._client.get_object(minio_client._bucket_name, name)
|
||||
try:
|
||||
content = response.read()
|
||||
finally:
|
||||
response.close()
|
||||
response.release_conn()
|
||||
text = content.decode("utf-8", errors="replace")
|
||||
if "tieneError>true<" in text:
|
||||
return "error", "tieneError=true detectado en XML"
|
||||
return "ok", None
|
||||
except Exception as e:
|
||||
return "no_verificable", f"excepción al leer archivo: {e}"
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Construcción del nombre de archivo de error
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _build_error_filename(self, old_name, pedimento_app, numero_partida, index=0):
|
||||
"""
|
||||
Retorna la ruta con nomenclatura de error:
|
||||
index=0 → {dir}/vu_PT_{pedimento_app}_{numero_partida}_ERROR.xml
|
||||
index>0 → {dir}/vu_PT_{pedimento_app}_{numero_partida}_ERROR_{index}.xml
|
||||
El índice evita colisión cuando una partida tiene más de un doc con error.
|
||||
"""
|
||||
dir_part = posixpath.dirname(old_name)
|
||||
suffix = f"_{index}" if index > 0 else ""
|
||||
new_filename = f"vu_PT_{pedimento_app}_{numero_partida}_ERROR{suffix}.xml"
|
||||
return posixpath.join(dir_part, new_filename)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Aplicación de correcciones
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@transaction.atomic
|
||||
def _apply_fix(self, partida, docs, pedimento_app):
|
||||
"""
|
||||
Renombra archivos en storage y actualiza BD dentro de una transacción.
|
||||
Nota: si la transacción revierte, los cambios en storage NO se deshacen.
|
||||
"""
|
||||
for idx, doc in enumerate(docs):
|
||||
new_name = self._build_error_filename(
|
||||
doc.archivo.name, pedimento_app, partida.numero_partida, idx
|
||||
)
|
||||
final_name = self._rename_in_storage(doc.archivo.name, new_name)
|
||||
doc.archivo = final_name
|
||||
doc.document_type_id = _PT_ERROR
|
||||
doc.vu = True
|
||||
doc.save(update_fields=["archivo", "document_type_id", "vu"])
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f" ✓ Doc {doc.id}: type=18 | {final_name}"
|
||||
))
|
||||
|
||||
partida.descargado = False
|
||||
partida.save(update_fields=["descargado"])
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f" ✓ Partida {partida.numero_partida}: descargado=False"
|
||||
))
|
||||
|
||||
def _rename_in_storage(self, old_name, new_name):
|
||||
if old_name == new_name:
|
||||
return old_name
|
||||
|
||||
if minio_client.file_exists(new_name):
|
||||
# Rename ya ocurrió en ejecución previa parcial
|
||||
self.stderr.write(self.style.WARNING(
|
||||
f" ⚠ ERROR ya existe en storage, usando: {new_name}"
|
||||
))
|
||||
if minio_client.file_exists(old_name):
|
||||
minio_client.delete_file(old_name)
|
||||
return new_name
|
||||
|
||||
if not minio_client.file_exists(old_name):
|
||||
self.stderr.write(self.style.WARNING(
|
||||
f" ⚠ Archivo no encontrado en storage: {old_name}"
|
||||
))
|
||||
return old_name
|
||||
|
||||
response = minio_client._client.get_object(minio_client._bucket_name, old_name)
|
||||
try:
|
||||
content = response.read()
|
||||
finally:
|
||||
response.close()
|
||||
response.release_conn()
|
||||
|
||||
minio_client.upload_file(new_name, file_data=io.BytesIO(content), content_type="application/xml")
|
||||
minio_client.delete_file(old_name)
|
||||
return new_name
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Resumen final
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _print_summary(self, total_peds, total_partidas, total_docs, dry_run):
|
||||
self.stdout.write(
|
||||
f"\n{'─' * 60}\nRESUMEN\n"
|
||||
f" Pedimentos malformados : {total_peds}\n"
|
||||
f" Partidas con descargado=True : {total_partidas}\n"
|
||||
f" Documentos con error VUCEM : {total_docs}\n"
|
||||
)
|
||||
if dry_run:
|
||||
self.stdout.write(self.style.WARNING(
|
||||
"\nMODO PRUEBA: ejecuta sin --dry-run para aplicar los cambios."
|
||||
))
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS("\nCorrección completada."))
|
||||
50
api/customs/migrations/0017_bulkuploadtask.py
Normal file
50
api/customs/migrations/0017_bulkuploadtask.py
Normal file
@@ -0,0 +1,50 @@
|
||||
# Generated by Django 5.2.3 on 2026-01-16 00:36
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('customs', '0016_alter_pedimento_unique_together'),
|
||||
('organization', '0002_remove_organizacion_membretado_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='BulkUploadTask',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('contribuyente', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('status', models.CharField(choices=[('pending', 'Pendiente'), ('processing', 'Procesando'), ('completed', 'Completado'), ('failed', 'Fallido'), ('partial', 'Parcialmente completado')], default='pending', max_length=20)),
|
||||
('task_type', models.CharField(default='bulk_create', max_length=50)),
|
||||
('total_files', models.IntegerField(default=0)),
|
||||
('processed_files', models.IntegerField(default=0)),
|
||||
('created_pedimentos', models.IntegerField(default=0)),
|
||||
('created_documents', models.IntegerField(default=0)),
|
||||
('result', models.JSONField(blank=True, default=dict)),
|
||||
('failed_files', models.JSONField(blank=True, default=list)),
|
||||
('error_message', models.TextField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('started_at', models.DateTimeField(blank=True, null=True)),
|
||||
('finished_at', models.DateTimeField(blank=True, null=True)),
|
||||
('fecha_pago', models.DateField(blank=True, null=True)),
|
||||
('clave_pedimento', models.CharField(blank=True, max_length=50, null=True)),
|
||||
('tipo_operacion_id', models.IntegerField(blank=True, null=True)),
|
||||
('curp_apoderado', models.CharField(blank=True, max_length=50, null=True)),
|
||||
('partidas', models.IntegerField(default=0)),
|
||||
('celery_task_id', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('organizacion', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='organization.organizacion')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bulk_upload_tasks', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Tarea de Carga Masiva',
|
||||
'verbose_name_plural': 'Tareas de Carga Masiva',
|
||||
'db_table': 'bulk_upload_task',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 5.2.3 on 2026-03-06 19:35
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('customs', '0017_bulkuploadtask'),
|
||||
('organization', '0002_remove_organizacion_membretado_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name='pedimento',
|
||||
unique_together={('organizacion', 'pedimento_app')},
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='BulkUploadTask',
|
||||
),
|
||||
]
|
||||
18
api/customs/migrations/0019_pedimento_consultar_vucem.py
Normal file
18
api/customs/migrations/0019_pedimento_consultar_vucem.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.3 on 2026-05-19 14:24
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('customs', '0018_alter_pedimento_unique_together_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='pedimento',
|
||||
name='consultar_vucem',
|
||||
field=models.BooleanField(default=False, help_text='Solo pedimentos originados desde datastage deben consultar VUCEM automáticamente'),
|
||||
),
|
||||
]
|
||||
@@ -184,7 +184,7 @@ class EDocumentSerializer(serializers.ModelSerializer):
|
||||
numero = str(obj.numero_edocument).strip()
|
||||
# id_pedimento = str(obj.pedimento_id).strip()
|
||||
|
||||
# excluir e documents de tipo request y de tipo error
|
||||
# excluir solo request (21, 25); errores (22, 26) se incluyen para detección en frontend
|
||||
qs = Document.objects.filter(
|
||||
pedimento=obj.pedimento,
|
||||
archivo__icontains=numero,
|
||||
@@ -240,15 +240,11 @@ class CoveSerializer(serializers.ModelSerializer):
|
||||
try:
|
||||
numero = str(obj.numero_cove).strip()
|
||||
|
||||
# Excluir los tipo de documento 20, 24, 23 y 19
|
||||
# 20 = error solicitud cove
|
||||
# 24 = error solicitud acuse cove
|
||||
# 23 = request acuse cove
|
||||
# 19 = request cove
|
||||
# Excluir solo request (19, 23); errores (20, 24) se incluyen para detección en frontend
|
||||
qs = Document.objects.filter(
|
||||
pedimento=obj.pedimento,
|
||||
archivo__icontains=numero,
|
||||
).exclude(document_type_id__in=[20, 24, 23, 19])
|
||||
).exclude(document_type_id__in=[19, 23])
|
||||
|
||||
# Filtro por organización si aplica
|
||||
if hasattr(obj, 'organizacion') and obj.organizacion:
|
||||
|
||||
@@ -6,9 +6,50 @@ from api.customs.models import ProcesamientoPedimento, Pedimento, Cove, EDocumen
|
||||
from core.utils import xml_controller
|
||||
import requests
|
||||
from core.utils import xml_remesas_controller
|
||||
from core.redis_events import publish_task_event
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _crear_notificacion_auditoria(user_id: str, task_id: str, label: str, resultado: dict):
|
||||
"""Crea una Notificacion persistente cuando una tarea de auditoría masiva completa."""
|
||||
try:
|
||||
from api.notificaciones.models import Notificacion, TipoNotificacion
|
||||
from api.cuser.models import CustomUser
|
||||
|
||||
tipo, _ = TipoNotificacion.objects.get_or_create(
|
||||
tipo="auditoria_completada",
|
||||
defaults={"descripcion": "Auditoría masiva completada"},
|
||||
)
|
||||
usuario = CustomUser.objects.filter(id=user_id).first()
|
||||
if not usuario:
|
||||
return
|
||||
|
||||
total = resultado.get('total_pedimentos', 0)
|
||||
completados = resultado.get('completados', resultado.get('procesados', 0))
|
||||
pendientes = resultado.get('con_pendientes', 0)
|
||||
errores = resultado.get('con_errores', 0)
|
||||
|
||||
partes = [f"Auditoría de {label} completada — {completados}/{total} pedimentos"]
|
||||
if pendientes:
|
||||
partes.append(f"{pendientes} con pendientes")
|
||||
if errores:
|
||||
partes.append(f"{errores} con errores")
|
||||
|
||||
Notificacion.objects.create(
|
||||
tipo=tipo,
|
||||
dirigido=usuario,
|
||||
mensaje=", ".join(partes),
|
||||
datos={
|
||||
"task_id": task_id,
|
||||
"label": label,
|
||||
"resultado": resultado,
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f"[auditoria] Error creando notificación para tarea {task_id}: {exc}")
|
||||
|
||||
def obtener_pedimentos(organizacion_id):
|
||||
return Pedimento.objects.filter(organizacion_id=organizacion_id)
|
||||
|
||||
@@ -129,19 +170,22 @@ def auditar_procesamiento_remesa_por_pedimento(pedimento_id):
|
||||
'pedimento_id': str(pedimento_id)
|
||||
}
|
||||
|
||||
@shared_task
|
||||
def crear_partidas(organizacion_id):
|
||||
@shared_task(bind=True)
|
||||
def crear_partidas(self, organizacion_id, user_id=None):
|
||||
from api.customs.models import Partida
|
||||
task_id = self.request.id
|
||||
|
||||
pedimentos = obtener_pedimentos(organizacion_id)
|
||||
total_pedimentos = pedimentos.count()
|
||||
|
||||
publish_task_event(task_id, "processing", f"Creando partidas: {total_pedimentos} pedimentos", progress=0)
|
||||
|
||||
completados = []
|
||||
con_pendientes = []
|
||||
sin_datos = []
|
||||
errores = []
|
||||
|
||||
for pedimento in pedimentos:
|
||||
for idx, pedimento in enumerate(pedimentos):
|
||||
try:
|
||||
if not pedimento.numero_partidas or pedimento.numero_partidas <= 0:
|
||||
sin_datos.append({
|
||||
@@ -180,8 +224,13 @@ def crear_partidas(organizacion_id):
|
||||
})
|
||||
logger.error(f"Error creando partidas para pedimento {pedimento.id}: {e}")
|
||||
|
||||
return {
|
||||
if total_pedimentos > 0 and (idx + 1) % 10 == 0:
|
||||
pct = int(((idx + 1) / total_pedimentos) * 100)
|
||||
publish_task_event(task_id, "processing", f"Creando partidas: {idx + 1}/{total_pedimentos}", progress=pct)
|
||||
|
||||
resultado = {
|
||||
'organizacion_id': str(organizacion_id),
|
||||
'auditoria': 'partidas',
|
||||
'total_pedimentos': total_pedimentos,
|
||||
'completados': len(completados),
|
||||
'con_pendientes': len(con_pendientes),
|
||||
@@ -192,6 +241,12 @@ def crear_partidas(organizacion_id):
|
||||
'detalle_errores': errores,
|
||||
}
|
||||
|
||||
publish_task_event(task_id, "completed", "Creación de partidas completada", resultado=resultado, progress=100)
|
||||
if user_id:
|
||||
_crear_notificacion_auditoria(user_id, task_id, "Partidas", resultado)
|
||||
|
||||
return resultado
|
||||
|
||||
@shared_task
|
||||
def crear_partidas_por_pedimento(pedimento_id):
|
||||
try:
|
||||
@@ -230,19 +285,23 @@ def crear_partidas_por_pedimento(pedimento_id):
|
||||
print(f"Error: Pedimento {pedimento_id} tiene numero_partidas inválido: {pedimento.numero_partidas}")
|
||||
logger.info(f"Error: Pedimento {pedimento_id} tiene numero_partidas inválido: {pedimento.numero_partidas}")
|
||||
|
||||
def _auditar_organizacion(organizacion_id, servicio, related_name, variable, label):
|
||||
def _auditar_organizacion(organizacion_id, servicio, related_name, variable, label, task_id=None, user_id=None):
|
||||
"""
|
||||
Itera todos los pedimentos de una organización auditando el campo `variable`
|
||||
en la relación `related_name`. Retorna un resumen estructurado por pedimento.
|
||||
Publica eventos SSE en Redis si se proporciona task_id.
|
||||
"""
|
||||
pedimentos = obtener_pedimentos(organizacion_id)
|
||||
total_pedimentos = pedimentos.count()
|
||||
|
||||
if task_id:
|
||||
publish_task_event(task_id, "processing", f"Auditando {label}: {total_pedimentos} pedimentos", progress=0)
|
||||
|
||||
completados = []
|
||||
pendientes = []
|
||||
errores = []
|
||||
|
||||
for pedimento in pedimentos:
|
||||
for idx, pedimento in enumerate(pedimentos):
|
||||
try:
|
||||
docs = list(getattr(pedimento, related_name).all())
|
||||
total = len(docs)
|
||||
@@ -266,6 +325,11 @@ def _auditar_organizacion(organizacion_id, servicio, related_name, variable, lab
|
||||
|
||||
modificar_estado_procesamiento(pedimento, servicio_id=servicio, nuevo_estado=nuevo_estado)
|
||||
|
||||
# Publicar progreso cada 10 pedimentos para no saturar Redis
|
||||
if task_id and total_pedimentos > 0 and (idx + 1) % 10 == 0:
|
||||
pct = int(((idx + 1) / total_pedimentos) * 100)
|
||||
publish_task_event(task_id, "processing", f"Auditando {label}: {idx + 1}/{total_pedimentos}", progress=pct)
|
||||
|
||||
except Exception as e:
|
||||
errores.append({
|
||||
'pedimento_id': str(pedimento.id),
|
||||
@@ -274,7 +338,7 @@ def _auditar_organizacion(organizacion_id, servicio, related_name, variable, lab
|
||||
})
|
||||
logger.error(f"Error auditando pedimento {pedimento.id} [{label}]: {e}")
|
||||
|
||||
return {
|
||||
resultado = {
|
||||
'organizacion_id': str(organizacion_id),
|
||||
'auditoria': label,
|
||||
'total_pedimentos': total_pedimentos,
|
||||
@@ -285,73 +349,89 @@ def _auditar_organizacion(organizacion_id, servicio, related_name, variable, lab
|
||||
'detalle_errores': errores,
|
||||
}
|
||||
|
||||
if task_id:
|
||||
publish_task_event(task_id, "completed", f"Auditoría de {label} completada", resultado=resultado, progress=100)
|
||||
if user_id:
|
||||
_crear_notificacion_auditoria(user_id, task_id, label, resultado)
|
||||
|
||||
@shared_task
|
||||
def auditar_coves(organizacion_id):
|
||||
return resultado
|
||||
|
||||
|
||||
@shared_task(bind=True)
|
||||
def auditar_coves(self, organizacion_id, user_id=None):
|
||||
return _auditar_organizacion(
|
||||
organizacion_id,
|
||||
servicio=8,
|
||||
related_name='coves',
|
||||
variable='cove_descargado',
|
||||
label='cove',
|
||||
task_id=self.request.id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
@shared_task
|
||||
def auditar_acuse_cove(organizacion_id):
|
||||
@shared_task(bind=True)
|
||||
def auditar_acuse_cove(self, organizacion_id, user_id=None):
|
||||
return _auditar_organizacion(
|
||||
organizacion_id,
|
||||
servicio=9,
|
||||
related_name='coves',
|
||||
variable='acuse_cove_descargado',
|
||||
label='acuse_cove',
|
||||
task_id=self.request.id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
@shared_task
|
||||
def auditar_edocuments(organizacion_id):
|
||||
@shared_task(bind=True)
|
||||
def auditar_edocuments(self, organizacion_id, user_id=None):
|
||||
return _auditar_organizacion(
|
||||
organizacion_id,
|
||||
servicio=7,
|
||||
related_name='documentos',
|
||||
variable='edocument_descargado',
|
||||
label='edocument',
|
||||
task_id=self.request.id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
@shared_task
|
||||
def auditar_acuse(organizacion_id):
|
||||
@shared_task(bind=True)
|
||||
def auditar_acuse(self, organizacion_id, user_id=None):
|
||||
return _auditar_organizacion(
|
||||
organizacion_id,
|
||||
servicio=6,
|
||||
related_name='documentos',
|
||||
variable='acuse_descargado',
|
||||
label='acuse',
|
||||
task_id=self.request.id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
@shared_task
|
||||
def auditar_remesas(organizacion_id):
|
||||
@shared_task(bind=True)
|
||||
def auditar_remesas(self, organizacion_id, user_id=None):
|
||||
"""
|
||||
Audita el estado de descarga de remesas para todos los pedimentos de una organización.
|
||||
A diferencia de coves/edocuments, las remesas no tienen campo booleano propio —
|
||||
se verifica la existencia de un documento de tipo 3 (Remesa) en el pedimento.
|
||||
"""
|
||||
task_id = self.request.id
|
||||
pedimentos = obtener_pedimentos(organizacion_id)
|
||||
total_pedimentos = pedimentos.count()
|
||||
|
||||
if task_id:
|
||||
publish_task_event(task_id, "processing", f"Auditando remesas: {total_pedimentos} pedimentos", progress=0)
|
||||
|
||||
completados = []
|
||||
pendientes = []
|
||||
errores = []
|
||||
|
||||
for pedimento in pedimentos:
|
||||
for idx, pedimento in enumerate(pedimentos):
|
||||
try:
|
||||
if not pedimento.remesas:
|
||||
# El pedimento no declara remesas — no aplica, marcar como completado
|
||||
modificar_estado_procesamiento(pedimento, servicio_id=5, nuevo_estado=3)
|
||||
completados.append(str(pedimento.id))
|
||||
elif pedimento.documents.filter(document_type=3).exists():
|
||||
# Documento de remesa ya descargado
|
||||
modificar_estado_procesamiento(pedimento, servicio_id=5, nuevo_estado=3)
|
||||
completados.append(str(pedimento.id))
|
||||
else:
|
||||
# Tiene remesas declaradas pero el documento aún no existe
|
||||
modificar_estado_procesamiento(pedimento, servicio_id=5, nuevo_estado=4)
|
||||
pendientes.append({
|
||||
'pedimento_id': str(pedimento.id),
|
||||
@@ -365,7 +445,11 @@ def auditar_remesas(organizacion_id):
|
||||
})
|
||||
logger.error(f"Error auditando remesa de pedimento {pedimento.id}: {e}")
|
||||
|
||||
return {
|
||||
if task_id and total_pedimentos > 0 and (idx + 1) % 10 == 0:
|
||||
pct = int(((idx + 1) / total_pedimentos) * 100)
|
||||
publish_task_event(task_id, "processing", f"Auditando remesas: {idx + 1}/{total_pedimentos}", progress=pct)
|
||||
|
||||
resultado = {
|
||||
'organizacion_id': str(organizacion_id),
|
||||
'auditoria': 'remesa',
|
||||
'total_pedimentos': total_pedimentos,
|
||||
@@ -376,6 +460,13 @@ def auditar_remesas(organizacion_id):
|
||||
'detalle_errores': errores,
|
||||
}
|
||||
|
||||
if task_id:
|
||||
publish_task_event(task_id, "completed", "Auditoría de remesas completada", resultado=resultado, progress=100)
|
||||
if user_id:
|
||||
_crear_notificacion_auditoria(user_id, task_id, "Remesas", resultado)
|
||||
|
||||
return resultado
|
||||
|
||||
@shared_task
|
||||
def auditar_cove_por_pedimento(pedimento_id):
|
||||
try:
|
||||
|
||||
477
api/customs/tasks/auto_corregir.py
Normal file
477
api/customs/tasks/auto_corregir.py
Normal file
@@ -0,0 +1,477 @@
|
||||
"""
|
||||
Tarea Celery: auto-corrección de pedimentos incompletos a partir de sus XMLs.
|
||||
|
||||
Busca pedimentos con consultar_vucem=False, analiza su documento XML más reciente
|
||||
en busca de una respuesta consultarPedimentoCompleto de VUCEM, y si el número de
|
||||
pedimento coincide, auto-corrige los campos faltantes en BD y reclasifica el documento.
|
||||
|
||||
Campos corregidos (solo si están vacíos/nulos en BD):
|
||||
numero_operacion, aduana, clave_pedimento, regimen, contribuyente (por RFC).
|
||||
|
||||
Acciones sobre el documento si el tipo no es 2 (Pedimento Completo):
|
||||
- Renombra el archivo en MinIO: vu_PC_{pedimento_app}.xml
|
||||
- Actualiza document_type_id → 2
|
||||
- Actualiza vu → False (tipo 2 no es VUCEM directo)
|
||||
|
||||
Al finalizar activa consultar_vucem=True en el pedimento.
|
||||
"""
|
||||
import io
|
||||
import logging
|
||||
import posixpath
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from celery import shared_task
|
||||
from django.db import transaction
|
||||
|
||||
from api.customs.models import Importador, Pedimento, Regimen
|
||||
from api.record.models import Document
|
||||
from api.utils.minio_client import minio_client
|
||||
from core.redis_events import publish_task_event
|
||||
|
||||
logger = logging.getLogger('api.customs.tasks.auto_corregir')
|
||||
|
||||
_DOC_TYPE_PC = 2 # Pedimento Completo (ya procesado — no volver a procesar)
|
||||
_PROGRESS_INTERVAL = 10 # Emitir progreso cada N pedimentos
|
||||
|
||||
# Tipos excluidos de la búsqueda:
|
||||
# 1 = Pedimento Partida (no contiene respuesta PC)
|
||||
# 2 = Pedimento Completo (ya procesado)
|
||||
# 13–26 = Tipos VUCEM: requests, errors de VU (peticiones salientes, no respuestas de contenido)
|
||||
_EXCLUDE_DOC_TYPES = frozenset(range(13, 27)) | {1, _DOC_TYPE_PC}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Helpers XML (namespace-agnostic)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _local(tag):
|
||||
return tag.split('}')[-1] if '}' in tag else tag
|
||||
|
||||
|
||||
def _find_text(root, local_name):
|
||||
"""Primer elemento con ese nombre local; retorna su texto o None."""
|
||||
for el in root.iter():
|
||||
if _local(el.tag) == local_name:
|
||||
text = (el.text or '').strip()
|
||||
return text or None
|
||||
return None
|
||||
|
||||
|
||||
def _find_child_text(root, parent_name, child_name):
|
||||
"""Texto del hijo directo child_name dentro del primer parent_name encontrado."""
|
||||
for el in root.iter():
|
||||
if _local(el.tag) == parent_name:
|
||||
for child in el:
|
||||
if _local(child.tag) == child_name:
|
||||
text = (child.text or '').strip()
|
||||
return text or None
|
||||
return None
|
||||
|
||||
|
||||
def _find_pedimento_number(root):
|
||||
"""
|
||||
Extrae el número de pedimento de la estructura anidada:
|
||||
<ns2:pedimento> ← contenedor
|
||||
<ns2:pedimento>XXXX</ns2:pedimento> ← número
|
||||
"""
|
||||
for el in root.iter():
|
||||
if _local(el.tag) == 'pedimento':
|
||||
for child in el:
|
||||
if _local(child.tag) == 'pedimento':
|
||||
text = (child.text or '').strip()
|
||||
return text or None
|
||||
return None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Helpers MinIO
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _read_from_minio(object_name):
|
||||
if not minio_client.file_exists(object_name):
|
||||
return None
|
||||
response = minio_client._client.get_object(minio_client._bucket_name, object_name)
|
||||
try:
|
||||
return response.read()
|
||||
finally:
|
||||
response.close()
|
||||
response.release_conn()
|
||||
|
||||
|
||||
def _rename_in_minio(old_name, new_name, content):
|
||||
if old_name == new_name:
|
||||
return old_name
|
||||
# Si ya existe en destino (ejecución previa parcial): limpiar origen
|
||||
if minio_client.file_exists(new_name):
|
||||
if minio_client.file_exists(old_name):
|
||||
minio_client.delete_file(old_name)
|
||||
return new_name
|
||||
minio_client.upload_file(new_name, file_data=io.BytesIO(content), content_type='application/xml')
|
||||
minio_client.delete_file(old_name)
|
||||
return new_name
|
||||
|
||||
|
||||
def _resolve_regimen(clave_pedimento, tipo_operacion_raw):
|
||||
"""
|
||||
Convierte clave_documento + tipo_operacion del XML al código de régimen,
|
||||
replicando la lógica de carga de datastage:
|
||||
Regimen.objects.filter(claveped=clave_pedimento, tipo=tipo_int).regimenped
|
||||
"""
|
||||
if not clave_pedimento or not tipo_operacion_raw:
|
||||
return None
|
||||
try:
|
||||
tipo_int = int(tipo_operacion_raw)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
regimen_obj = Regimen.objects.filter(claveped=clave_pedimento, tipo=tipo_int).first()
|
||||
return regimen_obj.regimenped if regimen_obj else None
|
||||
|
||||
|
||||
def _find_pc_document(pedimento):
|
||||
"""
|
||||
Busca entre los XMLs del pedimento el primero que contenga una respuesta
|
||||
consultarPedimentoCompleto de VUCEM.
|
||||
|
||||
Tipos incluidos: 3–12 (documentos de contenido: pedimento, remesas, acuse,
|
||||
edocument, estado, cove, digitalizacion, error, general).
|
||||
Tipos excluidos: 1 (partida), 2 (ya procesado), 13–26 (peticiones/errores VU).
|
||||
|
||||
Retorna (doc, content_bytes, object_name, hay_candidatos):
|
||||
- hay_candidatos=False → ningún XML candidato en BD
|
||||
- hay_candidatos=True, doc=None → hay XMLs pero ninguno es respuesta PC
|
||||
- doc!=None → encontrado
|
||||
"""
|
||||
qs = (
|
||||
Document.objects.filter(
|
||||
pedimento=pedimento,
|
||||
archivo__iendswith='.xml',
|
||||
)
|
||||
.exclude(document_type_id__in=_EXCLUDE_DOC_TYPES)
|
||||
.order_by('-created_at')
|
||||
)
|
||||
|
||||
hay_candidatos = False
|
||||
for doc in qs:
|
||||
if not doc.archivo:
|
||||
continue
|
||||
hay_candidatos = True
|
||||
object_name = doc.archivo.name
|
||||
try:
|
||||
content = _read_from_minio(object_name)
|
||||
except Exception as exc:
|
||||
logger.debug(f"[find_pc] {pedimento.pedimento_app} — error MinIO {object_name}: {exc}")
|
||||
continue
|
||||
if not content:
|
||||
continue
|
||||
if b'consultarPedimentoCompletoRespuesta' in content:
|
||||
return doc, content, object_name, True
|
||||
|
||||
return None, None, None, hay_candidatos
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Tarea principal
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@shared_task(bind=True, name='auto_corregir_pedamentos')
|
||||
def auto_corregir_pedamentos_task(self, organizacion_id, pedimento_id=None):
|
||||
"""
|
||||
Itera pedimentos con consultar_vucem=False de la organización.
|
||||
Si se proporciona pedimento_id, procesa solo ese pedimento.
|
||||
Por cada uno verifica si tiene un XML de pedimento completo válido
|
||||
y corrige BD + storage.
|
||||
"""
|
||||
task_id = self.request.id
|
||||
revisados = 0
|
||||
corregidos = 0
|
||||
ignorados = 0
|
||||
detalles = []
|
||||
|
||||
qs = Pedimento.objects.filter(consultar_vucem=False).order_by('pedimento_app')
|
||||
if pedimento_id:
|
||||
qs = qs.filter(id=pedimento_id)
|
||||
else:
|
||||
qs = qs.filter(organizacion_id=organizacion_id)
|
||||
|
||||
total = qs.count()
|
||||
logger.info(f"[auto_corregir] org={organizacion_id} — {total} pedimentos a revisar")
|
||||
|
||||
publish_task_event(task_id, 'processing', f'Iniciando: {total} pedimentos a revisar', progress=0)
|
||||
|
||||
for idx, pedimento in enumerate(qs.iterator(chunk_size=100)):
|
||||
revisados += 1
|
||||
|
||||
if total > 0 and (idx % _PROGRESS_INTERVAL == 0 or idx == total - 1):
|
||||
pct = int(((idx + 1) / total) * 95)
|
||||
publish_task_event(
|
||||
task_id, 'processing',
|
||||
f'Revisando {idx + 1}/{total}: {pedimento.pedimento_app}',
|
||||
progress=pct,
|
||||
)
|
||||
|
||||
# Buscar XML con respuesta de pedimento completo (evalúa todos, VUCEM primero)
|
||||
try:
|
||||
candidato, content, object_name, hay_candidatos = _find_pc_document(pedimento)
|
||||
except Exception as exc:
|
||||
logger.warning(f"[auto_corregir] {pedimento.pedimento_app} — error buscando PC: {exc}")
|
||||
ignorados += 1
|
||||
continue
|
||||
|
||||
if not candidato:
|
||||
ignorados += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
root = ET.fromstring(content)
|
||||
except ET.ParseError as exc:
|
||||
logger.warning(f"[auto_corregir] {pedimento.pedimento_app} — XML inválido: {exc}")
|
||||
ignorados += 1
|
||||
continue
|
||||
|
||||
tiene_error = _find_text(root, 'tieneError')
|
||||
if tiene_error and tiene_error.lower() == 'true':
|
||||
ignorados += 1
|
||||
continue
|
||||
|
||||
pedimento_xml = _find_pedimento_number(root)
|
||||
pedimento_bd = (pedimento.pedimento or '').strip()
|
||||
if not pedimento_xml or pedimento_xml != pedimento_bd:
|
||||
logger.info(
|
||||
f"[auto_corregir] {pedimento.pedimento_app} — número no coincide "
|
||||
f"(XML={pedimento_xml!r}, BD={pedimento_bd!r})"
|
||||
)
|
||||
ignorados += 1
|
||||
continue
|
||||
|
||||
# ── Extracción de campos ──────────────────
|
||||
numero_operacion = _find_text(root, 'numeroOperacion')
|
||||
aduana = _find_child_text(root, 'aduanaEntradaSalida', 'clave')
|
||||
clave_pedimento = _find_child_text(root, 'claveDocumento', 'clave')
|
||||
tipo_operacion_raw = _find_child_text(root, 'tipoOperacion', 'clave')
|
||||
regimen = _resolve_regimen(clave_pedimento, tipo_operacion_raw)
|
||||
rfc = _find_child_text(root, 'importadorExportador', 'rfc')
|
||||
|
||||
ped_fields = []
|
||||
if numero_operacion and not pedimento.numero_operacion:
|
||||
pedimento.numero_operacion = numero_operacion
|
||||
ped_fields.append('numero_operacion')
|
||||
if aduana and aduana != (pedimento.aduana or '').strip():
|
||||
pedimento.aduana = aduana
|
||||
ped_fields.append('aduana')
|
||||
if clave_pedimento and clave_pedimento != (pedimento.clave_pedimento or '').strip():
|
||||
pedimento.clave_pedimento = clave_pedimento
|
||||
ped_fields.append('clave_pedimento')
|
||||
if regimen and not pedimento.regimen:
|
||||
pedimento.regimen = regimen
|
||||
ped_fields.append('regimen')
|
||||
|
||||
if rfc:
|
||||
try:
|
||||
importador = Importador.objects.get(rfc=rfc)
|
||||
if pedimento.contribuyente_id != importador.rfc:
|
||||
pedimento.contribuyente_id = importador.rfc
|
||||
ped_fields.append('contribuyente')
|
||||
except Importador.DoesNotExist:
|
||||
pass
|
||||
|
||||
pedimento.consultar_vucem = True
|
||||
ped_fields.append('consultar_vucem')
|
||||
|
||||
# ── Renombrado de documento si no es tipo 2 ──
|
||||
doc_fields = ['document_type_id', 'vu']
|
||||
final_object_name = object_name
|
||||
|
||||
if candidato.document_type_id != _DOC_TYPE_PC:
|
||||
dir_part = posixpath.dirname(object_name)
|
||||
new_filename = f"vu_PC_{pedimento.pedimento_app}.xml"
|
||||
new_object_name = posixpath.join(dir_part, new_filename)
|
||||
try:
|
||||
final_object_name = _rename_in_minio(object_name, new_object_name, content)
|
||||
doc_fields.append('archivo')
|
||||
except Exception as exc:
|
||||
logger.error(f"[auto_corregir] {pedimento.pedimento_app} — error renombrando en MinIO: {exc}")
|
||||
|
||||
# ── Persistir cambios en BD ───────────────
|
||||
try:
|
||||
with transaction.atomic():
|
||||
pedimento.save(update_fields=ped_fields)
|
||||
candidato.document_type_id = _DOC_TYPE_PC
|
||||
candidato.vu = False
|
||||
if 'archivo' in doc_fields:
|
||||
candidato.archivo = final_object_name
|
||||
candidato.save(update_fields=doc_fields)
|
||||
except Exception as exc:
|
||||
logger.error(f"[auto_corregir] {pedimento.pedimento_app} — error guardando en BD: {exc}")
|
||||
ignorados += 1
|
||||
continue
|
||||
|
||||
corregidos += 1
|
||||
detalles.append({
|
||||
'pedimento': pedimento.pedimento_app,
|
||||
'accion': 'corregido',
|
||||
'campos_pedimento': ped_fields,
|
||||
'documento_final': final_object_name,
|
||||
})
|
||||
logger.info(f"[auto_corregir] {pedimento.pedimento_app} — corregido: {ped_fields}")
|
||||
|
||||
# Modo individual: encolar el procesamiento completo (remesas, partidas,
|
||||
# coves, edocs) forzando aunque ya exista el documento tipo 2.
|
||||
if pedimento_id:
|
||||
try:
|
||||
from .microservice_v2 import procesar_pedimento_completo_individual
|
||||
procesar_pedimento_completo_individual.delay(str(pedimento.id), force=True)
|
||||
logger.info(f"[auto_corregir] {pedimento.pedimento_app} — PC completo encolado (force)")
|
||||
except Exception as exc:
|
||||
logger.warning(f"[auto_corregir] {pedimento.pedimento_app} — no se pudo encolar PC: {exc}")
|
||||
|
||||
resultado = {
|
||||
'total_revisados': revisados,
|
||||
'corregidos': corregidos,
|
||||
'ignorados': ignorados,
|
||||
'detalles': detalles,
|
||||
}
|
||||
logger.info(f"[auto_corregir] org={organizacion_id} finalizado — {resultado}")
|
||||
|
||||
publish_task_event(task_id, 'completed', 'Auto-corrección finalizada', resultado=resultado, progress=100)
|
||||
return resultado
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Tarea de análisis (sin modificar nada)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _campos_a_corregir(pedimento, numero_operacion, aduana, clave_pedimento, regimen, rfc):
|
||||
"""Retorna la lista de campos que se corregirían y los valores que se asignarían."""
|
||||
campos = []
|
||||
if numero_operacion and not pedimento.numero_operacion:
|
||||
campos.append({'campo': 'numero_operacion', 'valor_actual': None, 'valor_nuevo': numero_operacion})
|
||||
if aduana and aduana != (pedimento.aduana or '').strip():
|
||||
campos.append({'campo': 'aduana', 'valor_actual': pedimento.aduana, 'valor_nuevo': aduana})
|
||||
if clave_pedimento and clave_pedimento != (pedimento.clave_pedimento or '').strip():
|
||||
campos.append({'campo': 'clave_pedimento', 'valor_actual': pedimento.clave_pedimento, 'valor_nuevo': clave_pedimento})
|
||||
if regimen and not pedimento.regimen:
|
||||
campos.append({'campo': 'regimen', 'valor_actual': None, 'valor_nuevo': regimen})
|
||||
if rfc:
|
||||
try:
|
||||
importador = Importador.objects.get(rfc=rfc)
|
||||
if pedimento.contribuyente_id != importador.rfc:
|
||||
campos.append({
|
||||
'campo': 'contribuyente',
|
||||
'valor_actual': pedimento.contribuyente_id,
|
||||
'valor_nuevo': rfc,
|
||||
})
|
||||
except Importador.DoesNotExist:
|
||||
pass
|
||||
return campos
|
||||
|
||||
|
||||
@shared_task(bind=True, name='auditar_pedamentos_incompletos')
|
||||
def auditar_pedamentos_incompletos_task(self, organizacion_id, pedimento_id=None):
|
||||
"""
|
||||
Análisis de solo lectura: reporta qué pedimentos serían corregidos y qué
|
||||
cambios se aplicarían, sin modificar BD ni storage.
|
||||
Si se proporciona pedimento_id, analiza solo ese pedimento.
|
||||
"""
|
||||
task_id = self.request.id
|
||||
revisados = 0
|
||||
corregibles = []
|
||||
sin_xml = 0
|
||||
xml_sin_pc = 0
|
||||
num_no_coincide = 0
|
||||
con_error_vucem = 0
|
||||
|
||||
# Individual: analiza el pedimento específico sin importar su estado de corrección.
|
||||
# Masivo: solo los pendientes (consultar_vucem=False).
|
||||
if pedimento_id:
|
||||
qs = Pedimento.objects.filter(id=pedimento_id).order_by('pedimento_app')
|
||||
else:
|
||||
qs = Pedimento.objects.filter(
|
||||
organizacion_id=organizacion_id, consultar_vucem=False
|
||||
).order_by('pedimento_app')
|
||||
|
||||
total = qs.count()
|
||||
logger.info(f"[auditar_incompletos] org={organizacion_id} — {total} pedimentos a analizar")
|
||||
|
||||
publish_task_event(task_id, 'processing', f'Iniciando análisis: {total} pedimentos', progress=0)
|
||||
|
||||
for idx, pedimento in enumerate(qs.iterator(chunk_size=100)):
|
||||
revisados += 1
|
||||
|
||||
if total > 0 and (idx % _PROGRESS_INTERVAL == 0 or idx == total - 1):
|
||||
pct = int(((idx + 1) / total) * 95)
|
||||
publish_task_event(
|
||||
task_id, 'processing',
|
||||
f'Analizando {idx + 1}/{total}: {pedimento.pedimento_app}',
|
||||
progress=pct,
|
||||
)
|
||||
|
||||
# Buscar XML con respuesta de pedimento completo (evalúa todos, VUCEM primero)
|
||||
try:
|
||||
candidato, content, object_name, hay_candidatos = _find_pc_document(pedimento)
|
||||
except Exception as exc:
|
||||
logger.warning(f"[auditar_incompletos] {pedimento.pedimento_app} — error buscando PC: {exc}")
|
||||
sin_xml += 1
|
||||
continue
|
||||
|
||||
if not candidato:
|
||||
if hay_candidatos:
|
||||
xml_sin_pc += 1
|
||||
else:
|
||||
sin_xml += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
root = ET.fromstring(content)
|
||||
except ET.ParseError:
|
||||
xml_sin_pc += 1
|
||||
continue
|
||||
|
||||
tiene_error = _find_text(root, 'tieneError')
|
||||
if tiene_error and tiene_error.lower() == 'true':
|
||||
con_error_vucem += 1
|
||||
continue
|
||||
|
||||
pedimento_xml = _find_pedimento_number(root)
|
||||
pedimento_bd = (pedimento.pedimento or '').strip()
|
||||
if not pedimento_xml or pedimento_xml != pedimento_bd:
|
||||
num_no_coincide += 1
|
||||
continue
|
||||
|
||||
numero_operacion = _find_text(root, 'numeroOperacion')
|
||||
aduana = _find_child_text(root, 'aduanaEntradaSalida', 'clave')
|
||||
clave_pedimento = _find_child_text(root, 'claveDocumento', 'clave')
|
||||
tipo_operacion_raw = _find_child_text(root, 'tipoOperacion', 'clave')
|
||||
regimen = _resolve_regimen(clave_pedimento, tipo_operacion_raw)
|
||||
rfc = _find_child_text(root, 'importadorExportador', 'rfc')
|
||||
|
||||
campos = _campos_a_corregir(pedimento, numero_operacion, aduana, clave_pedimento, regimen, rfc)
|
||||
|
||||
dir_part = posixpath.dirname(object_name)
|
||||
nombre_pc = posixpath.join(dir_part, f"vu_PC_{pedimento.pedimento_app}.xml")
|
||||
|
||||
corregibles.append({
|
||||
'pedimento_app': pedimento.pedimento_app,
|
||||
'pedimento_id': str(pedimento.id),
|
||||
'documento_actual': {
|
||||
'id': str(candidato.id),
|
||||
'archivo': object_name,
|
||||
'document_type_id': candidato.document_type_id,
|
||||
},
|
||||
'documento_nuevo_nombre': nombre_pc if candidato.document_type_id != _DOC_TYPE_PC else None,
|
||||
'campos_a_corregir': campos,
|
||||
'consultar_vucem': True,
|
||||
})
|
||||
|
||||
resultado = {
|
||||
'total_revisados': revisados,
|
||||
'corregibles': len(corregibles),
|
||||
'sin_xml_o_ilegible': sin_xml,
|
||||
'xml_no_es_pedimento_completo': xml_sin_pc,
|
||||
'numero_pedimento_no_coincide': num_no_coincide,
|
||||
'con_error_vucem': con_error_vucem,
|
||||
'pedimentos': corregibles,
|
||||
}
|
||||
logger.info(f"[auditar_incompletos] org={organizacion_id} finalizado — {resultado}")
|
||||
|
||||
publish_task_event(task_id, 'completed', 'Análisis finalizado', resultado=resultado, progress=100)
|
||||
return resultado
|
||||
@@ -1,6 +1,9 @@
|
||||
import logging
|
||||
from celery import shared_task, group
|
||||
from api.customs.models import ProcesamientoPedimento, Pedimento, Cove, EDocument
|
||||
from core.utils import xml_controller
|
||||
from core.redis_events import publish_task_event
|
||||
from api.customs.tasks.auditoria import _crear_notificacion_auditoria
|
||||
from api.customs.tasks.microservice import (
|
||||
procesar_cove_individual,
|
||||
procesar_acuse_individual,
|
||||
@@ -180,51 +183,88 @@ def crear_servicios(organizacion_id):
|
||||
crear_procesamiento_acuse_cove.apply_async(args=[str(pedimento.id)])
|
||||
crear_procesamiento_edocument.apply_async(args=[str(pedimento.id)])
|
||||
|
||||
@shared_task
|
||||
def auditar_pedimentos(organizacion_id):
|
||||
@shared_task(bind=True)
|
||||
def auditar_pedimentos(self, organizacion_id, user_id=None):
|
||||
_logger = logging.getLogger('api.customs.async_operations')
|
||||
task_id = self.request.id
|
||||
|
||||
pedimentos = Pedimento.objects.filter(organizacion_id=organizacion_id)
|
||||
for pedimento in pedimentos:
|
||||
total_pedimentos = pedimentos.count()
|
||||
|
||||
publish_task_event(task_id, "processing", f"Auditando pedimentos: {total_pedimentos} pedimentos", progress=0)
|
||||
|
||||
procesados = 0
|
||||
sin_xml = 0
|
||||
errores = []
|
||||
|
||||
for idx, pedimento in enumerate(pedimentos):
|
||||
pc = pedimento.documents.filter(document_type__id=2).first()
|
||||
if pc:
|
||||
with open(f'./media/{pc.archivo}', 'r') as f:
|
||||
xml_content = f.read()
|
||||
|
||||
xml_data = xml_controller.extract_data(xml_content)
|
||||
|
||||
pedimento.numero_operacion = xml_data.get('numero_operacion')
|
||||
pedimento.curp_apoderado = xml_data.get('curp_apoderado')
|
||||
pedimento.agente_aduanal = xml_data.get('agente_aduanal')
|
||||
pedimento.numero_partidas = xml_data.get('numero_partidas')
|
||||
pedimento.remesas = xml_data.get('remesas')
|
||||
pedimento.tipo_operacion__id = xml_data.get('tipo_operacion')
|
||||
pedimento.fecha_pago = xml_data.get('fecha_pago')
|
||||
pedimento.pedimento_app = xml_data.get('fecha_pago')[2:4] + "-" + pedimento.aduana[:2] + "-" + pedimento.patente + "-" + pedimento.pedimentodd
|
||||
|
||||
for edoc in xml_data.get('edocuments', []):
|
||||
EDocument.objects.get_or_create(
|
||||
pedimento=pedimento,
|
||||
organizacion=pedimento.organizacion,
|
||||
clave=edoc.get('clave'),
|
||||
descripcion=edoc.get('descripcion'),
|
||||
numero_edocument=edoc.get('complemento1')
|
||||
)
|
||||
|
||||
from django.db import IntegrityError
|
||||
try:
|
||||
for cove in xml_data.get('coves', []):
|
||||
try:
|
||||
Cove.objects.get_or_create(
|
||||
pedimento=pedimento,
|
||||
organizacion=pedimento.organizacion,
|
||||
numero_cove=cove
|
||||
)
|
||||
except IntegrityError:
|
||||
# Si ya existe por unique, recupera el objeto existente
|
||||
Cove.objects.get(numero_cove=cove)
|
||||
except:
|
||||
# Si ya existe por unique, recupera el objeto existente
|
||||
pass
|
||||
with open(f'./media/{pc.archivo}', 'r') as f:
|
||||
xml_content = f.read()
|
||||
|
||||
xml_data = xml_controller.extract_data(xml_content)
|
||||
|
||||
pedimento.numero_operacion = xml_data.get('numero_operacion')
|
||||
pedimento.curp_apoderado = xml_data.get('curp_apoderado')
|
||||
pedimento.agente_aduanal = xml_data.get('agente_aduanal')
|
||||
pedimento.numero_partidas = xml_data.get('numero_partidas')
|
||||
pedimento.remesas = xml_data.get('remesas')
|
||||
pedimento.tipo_operacion__id = xml_data.get('tipo_operacion')
|
||||
pedimento.fecha_pago = xml_data.get('fecha_pago')
|
||||
pedimento.pedimento_app = xml_data.get('fecha_pago')[2:4] + "-" + pedimento.aduana[:2] + "-" + pedimento.patente + "-" + pedimento.pedimentodd
|
||||
|
||||
for edoc in xml_data.get('edocuments', []):
|
||||
EDocument.objects.get_or_create(
|
||||
pedimento=pedimento,
|
||||
organizacion=pedimento.organizacion,
|
||||
clave=edoc.get('clave'),
|
||||
descripcion=edoc.get('descripcion'),
|
||||
numero_edocument=edoc.get('complemento1')
|
||||
)
|
||||
|
||||
from django.db import IntegrityError
|
||||
try:
|
||||
for cove in xml_data.get('coves', []):
|
||||
try:
|
||||
Cove.objects.get_or_create(
|
||||
pedimento=pedimento,
|
||||
organizacion=pedimento.organizacion,
|
||||
numero_cove=cove
|
||||
)
|
||||
except IntegrityError:
|
||||
# Si ya existe por unique, recupera el objeto existente
|
||||
Cove.objects.get(numero_cove=cove)
|
||||
except:
|
||||
pass
|
||||
|
||||
procesados += 1
|
||||
except Exception as e:
|
||||
errores.append({'pedimento_id': str(pedimento.id), 'error': str(e)})
|
||||
_logger.error(f"Error auditando pedimento {pedimento.id}: {e}")
|
||||
else:
|
||||
sin_xml += 1
|
||||
|
||||
if total_pedimentos > 0 and (idx + 1) % 10 == 0:
|
||||
pct = int(((idx + 1) / total_pedimentos) * 100)
|
||||
publish_task_event(task_id, "processing", f"Auditando pedimentos: {idx + 1}/{total_pedimentos}", progress=pct)
|
||||
|
||||
resultado = {
|
||||
'organizacion_id': str(organizacion_id),
|
||||
'auditoria': 'pedimentos',
|
||||
'total_pedimentos': total_pedimentos,
|
||||
'procesados': procesados,
|
||||
'sin_xml': sin_xml,
|
||||
'con_errores': len(errores),
|
||||
'detalle_errores': errores,
|
||||
}
|
||||
|
||||
publish_task_event(task_id, "completed", "Auditoría de pedimentos completada", resultado=resultado, progress=100)
|
||||
if user_id:
|
||||
_crear_notificacion_auditoria(user_id, task_id, "Pedimentos", resultado)
|
||||
|
||||
return resultado
|
||||
|
||||
@shared_task
|
||||
def crear_todos_los_servicios():
|
||||
|
||||
@@ -91,12 +91,18 @@ def procesar_coves_pedimento(pedimento_id):
|
||||
"credencial": credenciales_dict
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/all/coves",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
print(f"Servicio de COVEs enviado para pedimento {pedimento.pedimento}")
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/all/coves",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=60
|
||||
)
|
||||
response.raise_for_status()
|
||||
logging.info(f"COVEs encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
|
||||
except requests.exceptions.RequestException as e:
|
||||
logging.error(f"Error encolando COVEs para pedimento {pedimento.pedimento}: {e}")
|
||||
raise
|
||||
|
||||
@shared_task
|
||||
def procesar_acuse_coves_pedimento(pedimento_id):
|
||||
@@ -107,19 +113,25 @@ def procesar_acuse_coves_pedimento(pedimento_id):
|
||||
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)],
|
||||
"pedimento": pedimento_dict,
|
||||
"credencial": credenciales_dict
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/all/acuse/cove/",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
print(f"Servicio de acuses de COVEs enviado para pedimento {pedimento.pedimento}")
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/all/acuse/cove/",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=60
|
||||
)
|
||||
response.raise_for_status()
|
||||
logging.info(f"Acuses de COVEs encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
|
||||
except requests.exceptions.RequestException as e:
|
||||
logging.error(f"Error encolando acuses de COVEs para pedimento {pedimento.pedimento}: {e}")
|
||||
raise
|
||||
|
||||
@shared_task
|
||||
def procesar_edocs_pedimento(pedimento_id):
|
||||
@@ -130,19 +142,25 @@ def procesar_edocs_pedimento(pedimento_id):
|
||||
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)],
|
||||
"pedimento": pedimento_dict,
|
||||
"credencial": credenciales_dict
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/download/all/edocs/",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
print(f"Servicio de E-documents enviado para pedimento {pedimento.pedimento}")
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/download/all/edocs/",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=60
|
||||
)
|
||||
response.raise_for_status()
|
||||
logging.info(f"E-documents encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
|
||||
except requests.exceptions.RequestException as e:
|
||||
logging.error(f"Error encolando E-documents para pedimento {pedimento.pedimento}: {e}")
|
||||
raise
|
||||
|
||||
@shared_task
|
||||
def procesar_acuses_pedimento(pedimento_id):
|
||||
@@ -153,19 +171,25 @@ def procesar_acuses_pedimento(pedimento_id):
|
||||
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)],
|
||||
"pedimento": pedimento_dict,
|
||||
"credencial": credenciales_dict
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/all/acuse/pedimento/",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
print(f"Servicio de acuses enviado para pedimento {pedimento.pedimento}")
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/all/acuse/pedimento/",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=60
|
||||
)
|
||||
response.raise_for_status()
|
||||
logging.info(f"Acuses encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
|
||||
except requests.exceptions.RequestException as e:
|
||||
logging.error(f"Error encolando acuses para pedimento {pedimento.pedimento}: {e}")
|
||||
raise
|
||||
|
||||
@shared_task
|
||||
def procesar_partidas_pedimento(pedimento_id):
|
||||
@@ -176,19 +200,32 @@ def procesar_partidas_pedimento(pedimento_id):
|
||||
id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id
|
||||
).first()
|
||||
credenciales_dict = credenciales_to_dict(credenciales)
|
||||
|
||||
|
||||
partidas_pendientes = list(pedimento.partidas.filter(descargado=False))
|
||||
payload = {
|
||||
"partidas": [partida_to_dict(partida) for partida in pedimento.partidas.filter(descargado=False)],
|
||||
"partidas": [partida_to_dict(p) for p in partidas_pendientes],
|
||||
"pedimento": pedimento_dict,
|
||||
"credencial": credenciales_dict
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/all/partidas/",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
print(f"Servicio de partidas enviado para pedimento {pedimento.pedimento}")
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/all/partidas/",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=60
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
logging.info(
|
||||
f"Partidas encoladas para pedimento {pedimento.pedimento}: "
|
||||
f"{result.get('total', 0)} de {len(partidas_pendientes)}"
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
logging.error(
|
||||
f"Error encolando partidas para pedimento {pedimento.pedimento}: {e}"
|
||||
)
|
||||
raise
|
||||
|
||||
@shared_task
|
||||
def procesar_remesas_pedimento(pedimento_id):
|
||||
@@ -205,17 +242,23 @@ def procesar_remesas_pedimento(pedimento_id):
|
||||
"credencial": credenciales_dict
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/remesas",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
print(f"Servicio de remesas enviado para pedimento {pedimento.pedimento}")
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/remesas",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=60
|
||||
)
|
||||
response.raise_for_status()
|
||||
logging.info(f"Remesa encolada para pedimento {pedimento.pedimento}")
|
||||
except requests.exceptions.RequestException as e:
|
||||
logging.error(f"Error encolando remesa para pedimento {pedimento.pedimento}: {e}")
|
||||
raise
|
||||
|
||||
@shared_task
|
||||
def procesar_pedimento_completo_individual(pedimento_id):
|
||||
def procesar_pedimento_completo_individual(pedimento_id, force=False):
|
||||
pedimento = Pedimento.objects.get(id=pedimento_id)
|
||||
if not pedimento.documents.filter(document_type=2).exists(): # Tipo 2: Pedimento Completo
|
||||
if force or not pedimento.documents.filter(document_type=2).exists(): # Tipo 2: Pedimento Completo
|
||||
pedimento_dict = pedimento_to_dict(pedimento)
|
||||
credenciales = Vucem.objects.filter(
|
||||
id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id
|
||||
@@ -225,13 +268,19 @@ def procesar_pedimento_completo_individual(pedimento_id):
|
||||
"pedimento": pedimento_dict,
|
||||
"credencial": credenciales_dict
|
||||
}
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/pedimento_completo",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
print(f"Servicio enviado para pedimento {pedimento.pedimento}")
|
||||
return response
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/pedimento_completo",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=60
|
||||
)
|
||||
response.raise_for_status()
|
||||
logging.info(f"Pedimento completo encolado: {pedimento.pedimento}")
|
||||
return response
|
||||
except requests.exceptions.RequestException as e:
|
||||
logging.error(f"Error encolando pedimento completo {pedimento.pedimento}: {e}")
|
||||
raise
|
||||
|
||||
@shared_task
|
||||
def procesar_pedimentos_completos(organizacion_id):
|
||||
@@ -270,13 +319,18 @@ def procesar_pedimentos_completos(organizacion_id):
|
||||
url = f"{SERVICE_API_URL_V2}/services/pedimento_completo"
|
||||
dataJson = json.dumps(payload)
|
||||
|
||||
response = requests.post(
|
||||
url,
|
||||
data=dataJson,
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
# Aquí puedes continuar con el resto de tu lógica
|
||||
print(f"Servicio enviado para pedimento {pedimento.pedimento}")
|
||||
try:
|
||||
response = requests.post(
|
||||
url,
|
||||
data=dataJson,
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=60
|
||||
)
|
||||
response.raise_for_status()
|
||||
logging.info(f"Pedimento completo encolado: {pedimento.pedimento}")
|
||||
except requests.exceptions.RequestException as e:
|
||||
logging.error(f"Error encolando pedimento completo {pedimento.pedimento}: {e}")
|
||||
continue
|
||||
|
||||
@shared_task
|
||||
def procesar_remesas(organizacion_id):
|
||||
@@ -311,9 +365,11 @@ def procesar_remesas(organizacion_id):
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/remesas/",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"}
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=60
|
||||
)
|
||||
logger.info(f"Servicio enviado para pedimento {pedimento.pedimento} — status {response.status_code}")
|
||||
response.raise_for_status()
|
||||
logger.info(f"Remesa encolada para pedimento {pedimento.pedimento} — status {response.status_code}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error procesando remesa para pedimento {pedimento.pedimento}: {e}", exc_info=True)
|
||||
@@ -339,14 +395,18 @@ def procesar_coves(organizacion_id):
|
||||
"credencial": credenciales_dict
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/all/coves",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
# Aquí puedes continuar con el resto de tu lógica
|
||||
|
||||
print(f"Servicio enviado para pedimento {pedimento.pedimento}")
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/all/coves",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=60
|
||||
)
|
||||
response.raise_for_status()
|
||||
logging.info(f"COVEs encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
|
||||
except requests.exceptions.RequestException as e:
|
||||
logging.error(f"Error encolando COVEs para pedimento {pedimento.pedimento}: {e}")
|
||||
continue
|
||||
|
||||
@shared_task
|
||||
def procesar_acuse_coves(organizacion_id):
|
||||
@@ -370,14 +430,18 @@ def procesar_acuse_coves(organizacion_id):
|
||||
"credencial": credenciales_dict
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/all/acuse/cove/",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
# Aquí puedes continuar con el resto de tu lógica
|
||||
|
||||
print(f"Servicio enviado para pedimento {pedimento.pedimento}")
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/all/acuse/cove/",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=60
|
||||
)
|
||||
response.raise_for_status()
|
||||
logging.info(f"Acuses de COVEs encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
|
||||
except requests.exceptions.RequestException as e:
|
||||
logging.error(f"Error encolando acuses de COVEs para pedimento {pedimento.pedimento}: {e}")
|
||||
continue
|
||||
|
||||
@shared_task
|
||||
def procesar_acuses(organizacion_id):
|
||||
@@ -401,14 +465,18 @@ def procesar_acuses(organizacion_id):
|
||||
"credencial": credenciales_dict
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/all/acuse/pedimento/",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
# Aquí puedes continuar con el resto de tu lógica
|
||||
|
||||
print(f"Servicio enviado para pedimento {pedimento.pedimento}")
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/all/acuse/pedimento/",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=60
|
||||
)
|
||||
response.raise_for_status()
|
||||
logging.info(f"Acuses encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
|
||||
except requests.exceptions.RequestException as e:
|
||||
logging.error(f"Error encolando acuses para pedimento {pedimento.pedimento}: {e}")
|
||||
continue
|
||||
|
||||
@shared_task
|
||||
def procesar_edocs(organizacion_id):
|
||||
@@ -432,14 +500,18 @@ def procesar_edocs(organizacion_id):
|
||||
"credencial": credenciales_dict
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/download/all/edocs/",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
# Aquí puedes continuar con el resto de tu lógica
|
||||
|
||||
print(f"Servicio enviado para pedimento {pedimento.pedimento}")
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/download/all/edocs/",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=60
|
||||
)
|
||||
response.raise_for_status()
|
||||
logging.info(f"E-documents encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
|
||||
except requests.exceptions.RequestException as e:
|
||||
logging.error(f"Error encolando E-documents para pedimento {pedimento.pedimento}: {e}")
|
||||
continue
|
||||
|
||||
@shared_task
|
||||
def procesar_partidas(organizacion_id):
|
||||
@@ -447,29 +519,42 @@ def procesar_partidas(organizacion_id):
|
||||
organizacion_id=organizacion_id,
|
||||
partidas__isnull=False
|
||||
).distinct()
|
||||
|
||||
|
||||
for pedimento in pedimentos:
|
||||
if pedimento.partidas.filter(descargado=False).exists(): # Tipo 4: Partidas
|
||||
# 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()
|
||||
partidas_pendientes = list(pedimento.partidas.filter(descargado=False))
|
||||
if not partidas_pendientes:
|
||||
continue
|
||||
|
||||
credenciales_dict = credenciales_to_dict(credenciales)
|
||||
|
||||
payload = {
|
||||
"partidas": [partida_to_dict(partida) for partida in pedimento.partidas.filter(descargado=False)],
|
||||
"pedimento": pedimento_dict,
|
||||
"credencial": credenciales_dict
|
||||
}
|
||||
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 = {
|
||||
"partidas": [partida_to_dict(p) for p in partidas_pendientes],
|
||||
"pedimento": pedimento_dict,
|
||||
"credencial": credenciales_dict
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/all/partidas/",
|
||||
f"{SERVICE_API_URL_V2}/services/all/partidas/",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"}
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=60
|
||||
)
|
||||
# Aquí puedes continuar con el resto de tu lógica
|
||||
|
||||
print(f"Servicio enviado para pedimento {pedimento.pedimento}")
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
logging.info(
|
||||
f"Partidas encoladas para pedimento {pedimento.pedimento}: "
|
||||
f"{result.get('total', 0)} de {len(partidas_pendientes)}"
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
logging.error(
|
||||
f"Error encolando partidas para pedimento {pedimento.pedimento}: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
@shared_task
|
||||
def documentos_con_errores(organizacion_id):
|
||||
|
||||
@@ -62,6 +62,11 @@ from .views_auditor import (
|
||||
auditor_obtener_peticion_edocument_vu,
|
||||
auditor_obtener_respuesta_edocument_vu,
|
||||
auditar_pedimento_endpoint,
|
||||
procesar_pedimento_completo_endpoint,
|
||||
auto_corregir_pedamentos_endpoint,
|
||||
auditar_pedamentos_incompletos_endpoint,
|
||||
auditar_pedamento_incompleto_endpoint,
|
||||
auto_corregir_pedamento_endpoint,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
@@ -80,6 +85,11 @@ urlpatterns = [
|
||||
path('auditor/auditar-acuse/pedimento/', auditar_acuse_pedimento_endpoint, name='auditar-acuse-pedimento'),
|
||||
path('auditor/auditar-remesa/pedimento/', auditar_procesamiento_remesa_pedimento_endpoint, name='auditar-remesa-pedimento'),
|
||||
path('auditor/auditar-pedimento/', auditar_pedimento_endpoint, name='auditar-pedimento'),
|
||||
path('auditor/procesar-pedimento-completo/pedimento/', procesar_pedimento_completo_endpoint, name='procesar-pedimento-completo-pedimento'),
|
||||
path('auditor/auto-corregir-pedamentos/', auto_corregir_pedamentos_endpoint, name='auto-corregir-pedamentos'),
|
||||
path('auditor/auditar-pedamentos-incompletos/', auditar_pedamentos_incompletos_endpoint, name='auditar-pedamentos-incompletos'),
|
||||
path('auditor/auto-corregir-pedamento/', auto_corregir_pedamento_endpoint, name='auto-corregir-pedamento'),
|
||||
path('auditor/auditar-pedamento-incompleto/', auditar_pedamento_incompleto_endpoint, name='auditar-pedamento-incompleto'),
|
||||
|
||||
path('auditor/procesar-pedimentos/organizaciones/', auditor_procesar_pedimentos_organizacion, name='procesar-pedimentos-organizaciones'),
|
||||
path('auditor/peticion-respuesta/pedimento-vu/', auditar_peticion_respuesta_pedimento_completo, name='peticion-respuesta-pedimento-vu'),
|
||||
|
||||
@@ -2505,6 +2505,7 @@ class ViewSetEDocument(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
|
||||
'partial_update': 'edocuments.edit',
|
||||
'destroy': 'edocuments.delete',
|
||||
'bulk_delete_edocs_vu': 'edocuments.delete',
|
||||
'reset_acuse': 'edocuments.edit',
|
||||
}
|
||||
codename = perms.get(self.action, 'edocuments.view')
|
||||
return [IsAuthenticated(), require_permission(codename)()]
|
||||
@@ -2531,6 +2532,88 @@ class ViewSetEDocument(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
|
||||
def perform_destroy(self, instance):
|
||||
instance.delete()
|
||||
|
||||
@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.
|
||||
"""
|
||||
from api.record.models import Document, DocumentType
|
||||
import logging
|
||||
logger = logging.getLogger('api.customs.views')
|
||||
|
||||
edoc = self.get_object()
|
||||
|
||||
if not edoc.acuse_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(
|
||||
pedimento=edoc.pedimento,
|
||||
archivo__icontains=edoc.numero_edocument,
|
||||
document_type_id=4
|
||||
).exists()
|
||||
|
||||
if acuse_disponible:
|
||||
return Response(
|
||||
{"status": "El acuse está disponible correctamente", "acuse_disponible": True},
|
||||
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 = (
|
||||
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')
|
||||
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode='wb', suffix='.txt', delete=False
|
||||
) as f:
|
||||
f.write(error_content)
|
||||
tmp_path = f.name
|
||||
|
||||
pedimento_app = getattr(edoc.pedimento, 'pedimento_app', str(edoc.pedimento.pedimento))
|
||||
file_name = f"error_acuse_{edoc.numero_edocument}.txt"
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
edoc.acuse_descargado = False
|
||||
edoc.save()
|
||||
|
||||
serializer = self.get_serializer(edoc)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
class ViewSetCove(viewsets.ModelViewSet, OrganizacionFiltradaMixin):
|
||||
"""
|
||||
ViewSet for Cove model.
|
||||
|
||||
@@ -15,7 +15,8 @@ from .tasks.auditoria import (
|
||||
auditar_remesas,
|
||||
)
|
||||
from .tasks.internal_services import auditar_pedimentos
|
||||
from .tasks.microservice_v2 import procesar_pedimentos_completos
|
||||
from .tasks.microservice_v2 import procesar_pedimentos_completos, procesar_pedimento_completo_individual
|
||||
from .tasks.auto_corregir import auto_corregir_pedamentos_task, auditar_pedamentos_incompletos_task
|
||||
from api.customs.models import Pedimento
|
||||
from api.organization.models import Organizacion
|
||||
from api.record.models import Document
|
||||
@@ -25,6 +26,9 @@ import os
|
||||
from api.utils.storage_service import storage_service
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
_ERROR_DOCUMENT_TYPES = [10, 14, 16, 18, 20, 22, 24, 26]
|
||||
|
||||
logger = logging.getLogger('api.customs.views_auditor')
|
||||
|
||||
def get_document_content(documento):
|
||||
@@ -95,7 +99,7 @@ def crear_partidas_organizacion(request):
|
||||
if not user.is_superuser and str(user.organizacion.id) != organizacion_id:
|
||||
return Response({'error': 'No tiene permisos para esta organización'}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
task = crear_partidas.delay(organizacion_id)
|
||||
task = crear_partidas.delay(organizacion_id, user_id=str(user.id))
|
||||
|
||||
return Response({
|
||||
'organizacion_id': organizacion_id,
|
||||
@@ -225,7 +229,7 @@ def auditar_pedimentos_endpoint(request):
|
||||
)
|
||||
|
||||
# Ejecutar la tarea de auditoría
|
||||
task = auditar_pedimentos.delay(organizacion_id)
|
||||
task = auditar_pedimentos.delay(organizacion_id, user_id=str(user.id))
|
||||
message = f"Auditoría iniciada para la organización {organizacion_id}"
|
||||
|
||||
return Response({
|
||||
@@ -306,7 +310,7 @@ def auditar_procesamiento_remesa_pedimento_endpoint(request):
|
||||
|
||||
|
||||
def _lanzar_auditoria_organizacion(request, task_fn, label):
|
||||
"""Helper compartido para los 4 endpoints de auditoría masiva por organización."""
|
||||
"""Helper compartido para los endpoints de auditoría masiva por organización."""
|
||||
organizacion_id = request.data.get('organizacion_id')
|
||||
if not organizacion_id:
|
||||
return Response({'error': 'Debe proporcionar organizacion_id'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -315,12 +319,12 @@ def _lanzar_auditoria_organizacion(request, task_fn, label):
|
||||
if not user.is_superuser and str(user.organizacion.id) != organizacion_id:
|
||||
return Response({'error': 'No tiene permisos para esta organización'}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
task = task_fn.delay(organizacion_id)
|
||||
task = task_fn.delay(organizacion_id, user_id=str(user.id))
|
||||
return Response({
|
||||
'organizacion_id': organizacion_id,
|
||||
'auditoria': label,
|
||||
'task_id': task.id,
|
||||
'mensaje': f'Auditoría de {label} iniciada. Consulta el resultado en GET /api/tasks/status/{task.id}/',
|
||||
'mensaje': f'Auditoría de {label} iniciada. Usa el stream SSE para seguimiento en tiempo real.',
|
||||
}, status=status.HTTP_202_ACCEPTED)
|
||||
|
||||
|
||||
@@ -1680,98 +1684,155 @@ def auditor_obtener_respuesta_edocument_vu(request):
|
||||
@permission_classes([IsAuthenticated, require_permission('auditoria.process')])
|
||||
def auditar_pedimento_endpoint(request):
|
||||
"""
|
||||
Audita un pedimento específico verificando si existe su XML y extrayendo información.
|
||||
Audita el pedimento completo (PC): ¿está descargado? ¿se puede procesar?
|
||||
Incluye diagnóstico de campos y errores detectados por tipo de documento.
|
||||
"""
|
||||
pedimento_id = request.data.get('pedimento_id')
|
||||
|
||||
|
||||
if not pedimento_id:
|
||||
return Response(
|
||||
{'error': 'Debe proporcionar pedimento_id'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
# Validar permisos y existencia del pedimento
|
||||
pedimento = Pedimento.objects.get(id=pedimento_id)
|
||||
pedimento = Pedimento.objects.select_related(
|
||||
'organizacion', 'contribuyente'
|
||||
).get(id=pedimento_id)
|
||||
user = request.user
|
||||
|
||||
|
||||
if not user.is_superuser and str(pedimento.organizacion.id) != str(user.organizacion.id):
|
||||
return Response(
|
||||
{'error': 'No tiene permisos para este pedimento'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
# Buscar documentos XML del pedimento
|
||||
documentos_xml = Document.objects.filter(
|
||||
pedimento=pedimento,
|
||||
archivo__endswith='.xml',
|
||||
|
||||
# PC descargado (type 2)
|
||||
pc_descargado = pedimento.documents.filter(
|
||||
document_type_id=2,
|
||||
organizacion=pedimento.organizacion
|
||||
).exists()
|
||||
|
||||
# Fuente de carga
|
||||
fuente = 'datastage' if pedimento.consultar_vucem else 'manual'
|
||||
|
||||
# Diagnóstico de campos
|
||||
aduana = pedimento.aduana or ''
|
||||
patente = pedimento.patente or ''
|
||||
numero_pedimento = pedimento.pedimento or ''
|
||||
|
||||
aduana_valida = bool(aduana) and aduana.isdigit() and 2 <= len(aduana) <= 3
|
||||
patente_valida = bool(patente) and patente.isdigit() and len(patente) == 4
|
||||
pedimento_valido = bool(numero_pedimento) and numero_pedimento.isdigit() and len(numero_pedimento) >= 7
|
||||
numero_operacion_presente = bool(pedimento.numero_operacion)
|
||||
|
||||
from api.vucem.models import CredencialesImportador
|
||||
tiene_contribuyente = pedimento.contribuyente is not None
|
||||
tiene_credenciales = False
|
||||
credenciales_detalle = None
|
||||
if tiene_contribuyente:
|
||||
credencial = CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first()
|
||||
tiene_credenciales = bool(credencial and credencial.vucem)
|
||||
if credencial and not credencial.vucem:
|
||||
credenciales_detalle = 'Credencial encontrada pero sin cuenta VUCEM asociada'
|
||||
elif not credencial:
|
||||
credenciales_detalle = f'Sin credenciales VUCEM para RFC {pedimento.contribuyente.rfc}'
|
||||
|
||||
razones = []
|
||||
if not aduana_valida:
|
||||
razones.append(f'Aduana inválida o ausente (valor: "{aduana}")')
|
||||
if not patente_valida:
|
||||
razones.append(f'Patente inválida o ausente (valor: "{patente}")')
|
||||
if not pedimento_valido:
|
||||
razones.append(f'Número de pedimento inválido (valor: "{numero_pedimento}")')
|
||||
if not tiene_contribuyente:
|
||||
razones.append('Sin contribuyente asignado')
|
||||
elif not tiene_credenciales:
|
||||
razones.append(credenciales_detalle or 'Sin credenciales VUCEM')
|
||||
|
||||
puede_procesar = len(razones) == 0
|
||||
|
||||
datos = {
|
||||
'aduana': aduana or None,
|
||||
'patente': patente or None,
|
||||
'numero_pedimento': numero_pedimento or None,
|
||||
'numero_operacion': pedimento.numero_operacion,
|
||||
'contribuyente_rfc': pedimento.contribuyente.rfc if pedimento.contribuyente else None,
|
||||
'contribuyente_nombre': str(pedimento.contribuyente) if pedimento.contribuyente else None,
|
||||
}
|
||||
|
||||
validacion = {
|
||||
'aduana_valida': aduana_valida,
|
||||
'patente_valida': patente_valida,
|
||||
'pedimento_valido': pedimento_valido,
|
||||
'numero_operacion_presente': numero_operacion_presente,
|
||||
'tiene_contribuyente': tiene_contribuyente,
|
||||
'tiene_credenciales_vucem': tiene_credenciales,
|
||||
}
|
||||
|
||||
# Errores por tipo de documento
|
||||
docs_error = (
|
||||
Document.objects
|
||||
.filter(
|
||||
pedimento=pedimento,
|
||||
organizacion=pedimento.organizacion,
|
||||
document_type_id__in=_ERROR_DOCUMENT_TYPES,
|
||||
)
|
||||
.select_related('document_type')
|
||||
.order_by('document_type_id')
|
||||
)
|
||||
|
||||
if not documentos_xml.exists():
|
||||
return Response({
|
||||
'pedimento_id': str(pedimento_id),
|
||||
'pedimento': pedimento.pedimento,
|
||||
'pedimento_app': pedimento.pedimento_app,
|
||||
'archivos_xml_encontrados': 0,
|
||||
'mensaje': 'No se encontraron archivos XML para este pedimento',
|
||||
'auditoria_completa': False
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
# Lista para almacenar información de cada XML
|
||||
xmls_analizados = []
|
||||
informacion_extraida = []
|
||||
|
||||
for documento in documentos_xml:
|
||||
errores_detectados = [
|
||||
{
|
||||
'documento_id': str(doc.id),
|
||||
'nombre_archivo': os.path.basename(str(doc.archivo)),
|
||||
'tipo_error': doc.document_type.descripcion if doc.document_type else 'Error desconocido',
|
||||
'tipo_id': doc.document_type_id,
|
||||
}
|
||||
for doc in docs_error
|
||||
]
|
||||
|
||||
print(f"documento >>>> {documento}")
|
||||
logger.info(f"documento >>>> {documento}")
|
||||
# XML del PC si existe
|
||||
informacion_xml = None
|
||||
doc_pc = pedimento.documents.filter(
|
||||
document_type_id=2,
|
||||
organizacion=pedimento.organizacion,
|
||||
archivo__endswith='.xml',
|
||||
).first()
|
||||
if doc_pc:
|
||||
xml_content = get_document_content(doc_pc)
|
||||
if xml_content:
|
||||
info_pedimento = extraer_info_pedimento_xml(xml_content)
|
||||
if info_pedimento:
|
||||
informacion_xml = info_pedimento
|
||||
actualizar_info_pedimento(pedimento, info_pedimento)
|
||||
|
||||
try:
|
||||
xml_info = {
|
||||
'documento_id': str(documento.id),
|
||||
'nombre_archivo': os.path.basename(str(documento.archivo)),
|
||||
'tamanio': documento.size,
|
||||
'extension': documento.extension,
|
||||
'tipo_documento': documento.document_type.descripcion if documento.document_type else 'Desconocido'
|
||||
}
|
||||
|
||||
xml_content = get_document_content(documento)
|
||||
|
||||
if xml_content is None:
|
||||
xml_info['error_lectura'] = 'No se pudo descargar el archivo'
|
||||
else:
|
||||
info_pedimento = extraer_info_pedimento_xml(xml_content)
|
||||
|
||||
if info_pedimento:
|
||||
xml_info['informacion_extraida'] = info_pedimento
|
||||
informacion_extraida.append(info_pedimento)
|
||||
hay_pendientes = not pc_descargado
|
||||
hay_errores = bool(errores_detectados)
|
||||
|
||||
# Actualizar el pedimento con la información encontrada si es necesario
|
||||
actualizar_info_pedimento(pedimento, info_pedimento)
|
||||
|
||||
xmls_analizados.append(xml_info)
|
||||
|
||||
except Exception as e:
|
||||
xmls_analizados.append({
|
||||
'documento_id': str(documento.id),
|
||||
'nombre_archivo': os.path.basename(str(documento.archivo)),
|
||||
'error': f'Error procesando archivo: {str(e)}'
|
||||
})
|
||||
|
||||
response_data = {
|
||||
if hay_errores:
|
||||
estado = 'CON_ERRORES'
|
||||
elif hay_pendientes:
|
||||
estado = 'PENDIENTE'
|
||||
else:
|
||||
estado = 'COMPLETO'
|
||||
|
||||
return Response({
|
||||
'pedimento_id': str(pedimento_id),
|
||||
'pedimento': pedimento.pedimento,
|
||||
'pedimento_app': pedimento.pedimento_app,
|
||||
'archivos_xml_encontrados': len(xmls_analizados),
|
||||
'xmls_analizados': xmls_analizados,
|
||||
'informacion_extraida': informacion_extraida,
|
||||
'auditoria_completa': True,
|
||||
'mensaje': f'Auditoría completada para el pedimento {pedimento.pedimento}'
|
||||
}
|
||||
|
||||
return Response(response_data, status=status.HTTP_200_OK)
|
||||
|
||||
'estado': estado,
|
||||
'hay_pendientes': hay_pendientes,
|
||||
'hay_errores': hay_errores,
|
||||
'pc_descargado': pc_descargado,
|
||||
'puede_procesar': puede_procesar,
|
||||
'razones_no_puede_procesar': razones,
|
||||
'fuente': fuente,
|
||||
'datos': datos,
|
||||
'validacion': validacion,
|
||||
'errores_detectados': errores_detectados,
|
||||
'informacion_xml': informacion_xml,
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
except Pedimento.DoesNotExist:
|
||||
return Response(
|
||||
{'error': 'Pedimento no encontrado'},
|
||||
@@ -1783,6 +1844,166 @@ def auditar_pedimento_endpoint(request):
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
@swagger_auto_schema(
|
||||
method='post',
|
||||
operation_description="Procesa el pedimento completo (tipo 2) de un pedimento específico llamando al microservicio VUCEM.",
|
||||
request_body=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
properties={
|
||||
'pedimento_id': openapi.Schema(type=openapi.TYPE_STRING, description='ID del pedimento'),
|
||||
},
|
||||
required=['pedimento_id']
|
||||
),
|
||||
responses={
|
||||
202: openapi.Response('Procesamiento encolado — usar task_id para consultar resultado'),
|
||||
200: openapi.Response('El pedimento ya tiene su documento completo descargado'),
|
||||
400: openapi.Response('Error en los parámetros o prerequisitos faltantes'),
|
||||
403: openapi.Response('No tiene permisos suficientes'),
|
||||
404: openapi.Response('Pedimento no encontrado'),
|
||||
}
|
||||
)
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated, require_permission('auditoria.process')])
|
||||
def procesar_pedimento_completo_endpoint(request):
|
||||
"""
|
||||
Diagnostica el pedimento completo y, si todo está en orden y aún no se ha
|
||||
descargado, encola la tarea de procesamiento.
|
||||
|
||||
Siempre devuelve diagnóstico completo: validación de campos, fuente, estado
|
||||
del PC y razones por las que no se puede procesar si aplica.
|
||||
"""
|
||||
pedimento_id = request.data.get('pedimento_id')
|
||||
|
||||
if not pedimento_id:
|
||||
return Response(
|
||||
{'error': 'Debe proporcionar pedimento_id'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
pedimento = Pedimento.objects.select_related(
|
||||
'organizacion', 'contribuyente', 'tipo_operacion'
|
||||
).get(id=pedimento_id)
|
||||
except Pedimento.DoesNotExist:
|
||||
return Response({'error': 'Pedimento no encontrado'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
user = request.user
|
||||
if not user.is_superuser and str(pedimento.organizacion.id) != str(user.organizacion.id):
|
||||
return Response({'error': 'No tiene permisos para este pedimento'}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
# --- Diagnóstico de campos ---
|
||||
aduana = pedimento.aduana or ''
|
||||
patente = pedimento.patente or ''
|
||||
numero_pedimento = pedimento.pedimento or ''
|
||||
|
||||
aduana_valida = bool(aduana) and aduana.isdigit() and 2 <= len(aduana) <= 3
|
||||
patente_valida = bool(patente) and patente.isdigit() and len(patente) == 4
|
||||
pedimento_valido = bool(numero_pedimento) and numero_pedimento.isdigit() and len(numero_pedimento) >= 7
|
||||
numero_operacion_presente = bool(pedimento.numero_operacion)
|
||||
|
||||
# --- Fuente de carga ---
|
||||
# consultar_vucem=True indica que fue originado desde datastage
|
||||
fuente = 'datastage' if pedimento.consultar_vucem else 'manual'
|
||||
|
||||
# --- Estado del PC ---
|
||||
pc_descargado = pedimento.documents.filter(
|
||||
document_type_id=2,
|
||||
organizacion=pedimento.organizacion
|
||||
).exists()
|
||||
|
||||
# --- Credenciales VUCEM ---
|
||||
from api.vucem.models import CredencialesImportador
|
||||
tiene_contribuyente = pedimento.contribuyente is not None
|
||||
tiene_credenciales = False
|
||||
credenciales_detalle = None
|
||||
if tiene_contribuyente:
|
||||
credencial = CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first()
|
||||
tiene_credenciales = bool(credencial and credencial.vucem)
|
||||
if credencial and not credencial.vucem:
|
||||
credenciales_detalle = 'Credencial encontrada pero sin cuenta VUCEM asociada'
|
||||
elif not credencial:
|
||||
credenciales_detalle = f'Sin credenciales VUCEM para RFC {pedimento.contribuyente.rfc}'
|
||||
|
||||
# --- Puede procesar ---
|
||||
razones = []
|
||||
if not aduana_valida:
|
||||
razones.append(f'Aduana inválida o ausente (valor: "{aduana}")')
|
||||
if not patente_valida:
|
||||
razones.append(f'Patente inválida o ausente (valor: "{patente}")')
|
||||
if not pedimento_valido:
|
||||
razones.append(f'Número de pedimento inválido (valor: "{numero_pedimento}")')
|
||||
if not tiene_contribuyente:
|
||||
razones.append('Sin contribuyente asignado')
|
||||
elif not tiene_credenciales:
|
||||
razones.append(credenciales_detalle or 'Sin credenciales VUCEM')
|
||||
|
||||
puede_procesar = len(razones) == 0
|
||||
|
||||
datos = {
|
||||
'aduana': aduana or None,
|
||||
'patente': patente or None,
|
||||
'numero_pedimento': numero_pedimento or None,
|
||||
'numero_operacion': pedimento.numero_operacion,
|
||||
'regimen': pedimento.regimen,
|
||||
'clave_pedimento': pedimento.clave_pedimento,
|
||||
'fecha_pago': str(pedimento.fecha_pago) if pedimento.fecha_pago else None,
|
||||
'contribuyente_rfc': pedimento.contribuyente.rfc if pedimento.contribuyente else None,
|
||||
'contribuyente_nombre': str(pedimento.contribuyente) if pedimento.contribuyente else None,
|
||||
'remesas': pedimento.remesas,
|
||||
'numero_partidas': pedimento.numero_partidas,
|
||||
}
|
||||
|
||||
validacion = {
|
||||
'aduana_valida': aduana_valida,
|
||||
'patente_valida': patente_valida,
|
||||
'pedimento_valido': pedimento_valido,
|
||||
'numero_operacion_presente': numero_operacion_presente,
|
||||
'tiene_contribuyente': tiene_contribuyente,
|
||||
'tiene_credenciales_vucem': tiene_credenciales,
|
||||
'puede_procesar': puede_procesar,
|
||||
'razones_no_puede_procesar': razones,
|
||||
}
|
||||
|
||||
base_response = {
|
||||
'pedimento_id': str(pedimento_id),
|
||||
'pedimento': pedimento.pedimento,
|
||||
'pedimento_app': pedimento.pedimento_app,
|
||||
'fuente': fuente,
|
||||
'datos': datos,
|
||||
'validacion': validacion,
|
||||
'pc_descargado': pc_descargado,
|
||||
}
|
||||
|
||||
force = bool(request.data.get('force', False))
|
||||
|
||||
# Ya descargado — solo bloquear si no es forzado
|
||||
if pc_descargado and not force:
|
||||
return Response({
|
||||
**base_response,
|
||||
'estado': 'ya_descargado',
|
||||
'mensaje': 'El pedimento completo ya fue descargado. Usa force=true para reprocesar remesas, partidas y documentos derivados.',
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
# No puede procesar — devolver diagnóstico con razones
|
||||
if not puede_procesar:
|
||||
return Response({
|
||||
**base_response,
|
||||
'estado': 'no_puede_procesar',
|
||||
'mensaje': 'El pedimento no cumple los requisitos para procesar',
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
# Todo en orden — encolar
|
||||
task = procesar_pedimento_completo_individual.delay(str(pedimento_id), force=force)
|
||||
logger.info(f"Procesamiento PC encolado: {pedimento.pedimento} (task={task.id})")
|
||||
|
||||
return Response({
|
||||
**base_response,
|
||||
'estado': 'encolado',
|
||||
'task_id': task.id,
|
||||
'mensaje': f'Procesamiento encolado para {pedimento.pedimento_app}',
|
||||
}, status=status.HTTP_202_ACCEPTED)
|
||||
|
||||
|
||||
def actualizar_info_pedimento(pedimento, info_xml):
|
||||
"""
|
||||
Actualiza la información del pedimento con los datos extraídos del XML.
|
||||
@@ -1873,8 +2094,226 @@ def actualizar_info_pedimento(pedimento, info_xml):
|
||||
if actualizado:
|
||||
pedimento.save()
|
||||
return True
|
||||
|
||||
|
||||
return False
|
||||
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Auto-corrección de pedimentos incompletos
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@swagger_auto_schema(
|
||||
method='post',
|
||||
operation_description=(
|
||||
"Encola una tarea Celery que analiza los XMLs de pedimentos con "
|
||||
"consultar_vucem=False, extrae datos del pedimento completo VUCEM y "
|
||||
"auto-corrige los campos faltantes (numero_operacion, aduana, "
|
||||
"clave_pedimento, regimen, contribuyente). El documento se reclasifica "
|
||||
"a tipo 2 (Pedimento Completo) y se activa consultar_vucem=True."
|
||||
),
|
||||
request_body=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
required=['organizacion_id'],
|
||||
properties={
|
||||
'organizacion_id': openapi.Schema(
|
||||
type=openapi.TYPE_STRING,
|
||||
description='UUID de la organización a procesar',
|
||||
),
|
||||
},
|
||||
),
|
||||
responses={
|
||||
202: openapi.Response('Tarea encolada correctamente'),
|
||||
400: openapi.Response('organizacion_id faltante'),
|
||||
404: openapi.Response('Organización no encontrada'),
|
||||
},
|
||||
)
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated, require_permission('auditoria.view')])
|
||||
def auto_corregir_pedamentos_endpoint(request):
|
||||
organizacion_id = request.data.get('organizacion_id')
|
||||
if not organizacion_id:
|
||||
return Response(
|
||||
{'error': 'organizacion_id es requerido'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
Organizacion.objects.get(id=organizacion_id)
|
||||
except Organizacion.DoesNotExist:
|
||||
return Response(
|
||||
{'error': 'Organización no encontrada'},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
task = auto_corregir_pedamentos_task.delay(str(organizacion_id))
|
||||
logger.info(
|
||||
f"[auto_corregir] tarea encolada — org={organizacion_id} task={task.id}"
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
'task_id': task.id,
|
||||
'organizacion': str(organizacion_id),
|
||||
'mensaje': (
|
||||
'Tarea encolada. Se analizarán los pedimentos con '
|
||||
'consultar_vucem=False de la organización.'
|
||||
),
|
||||
},
|
||||
status=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
|
||||
|
||||
@swagger_auto_schema(
|
||||
method='post',
|
||||
operation_description=(
|
||||
"Análisis de solo lectura: detecta pedimentos con consultar_vucem=False "
|
||||
"que podrían corregirse automáticamente. No modifica BD ni storage. "
|
||||
"Retorna el listado de pedimentos corregibles, los campos que cambiarían "
|
||||
"y el nuevo nombre de documento que se asignaría."
|
||||
),
|
||||
request_body=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
required=['organizacion_id'],
|
||||
properties={
|
||||
'organizacion_id': openapi.Schema(
|
||||
type=openapi.TYPE_STRING,
|
||||
description='UUID de la organización a analizar',
|
||||
),
|
||||
},
|
||||
),
|
||||
responses={
|
||||
202: openapi.Response('Tarea de análisis encolada correctamente'),
|
||||
400: openapi.Response('organizacion_id faltante'),
|
||||
404: openapi.Response('Organización no encontrada'),
|
||||
},
|
||||
)
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated, require_permission('auditoria.view')])
|
||||
def auditar_pedamentos_incompletos_endpoint(request):
|
||||
organizacion_id = request.data.get('organizacion_id')
|
||||
if not organizacion_id:
|
||||
return Response(
|
||||
{'error': 'organizacion_id es requerido'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
Organizacion.objects.get(id=organizacion_id)
|
||||
except Organizacion.DoesNotExist:
|
||||
return Response(
|
||||
{'error': 'Organización no encontrada'},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
task = auditar_pedamentos_incompletos_task.delay(str(organizacion_id))
|
||||
logger.info(
|
||||
f"[auditar_incompletos] tarea encolada — org={organizacion_id} task={task.id}"
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
'task_id': task.id,
|
||||
'organizacion': str(organizacion_id),
|
||||
'mensaje': (
|
||||
'Tarea de análisis encolada. Se reportarán los pedimentos con '
|
||||
'consultar_vucem=False que podrían corregirse automáticamente, '
|
||||
'sin modificar nada.'
|
||||
),
|
||||
},
|
||||
status=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
|
||||
|
||||
@swagger_auto_schema(
|
||||
method='post',
|
||||
operation_description="Analiza un pedimento específico para auto-corrección (solo lectura, sin modificar BD). Acepta pedimento_id (UUID) o pedimento_app.",
|
||||
request_body=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
properties={
|
||||
'pedimento_id': openapi.Schema(type=openapi.TYPE_STRING, description='UUID del pedimento'),
|
||||
'pedimento_app': openapi.Schema(type=openapi.TYPE_STRING, description='Número de pedimento (ej: 21-80-3452-1004463)'),
|
||||
},
|
||||
),
|
||||
responses={
|
||||
202: openapi.Response('Tarea de análisis encolada'),
|
||||
400: openapi.Response('Parámetro faltante'),
|
||||
404: openapi.Response('Pedimento no encontrado'),
|
||||
},
|
||||
)
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated, require_permission('auditoria.view')])
|
||||
def auditar_pedamento_incompleto_endpoint(request):
|
||||
pedimento_id = request.data.get('pedimento_id')
|
||||
pedimento_app = request.data.get('pedimento_app')
|
||||
if not pedimento_id and not pedimento_app:
|
||||
return Response({'error': 'pedimento_id o pedimento_app es requerido'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
if pedimento_id:
|
||||
pedimento = Pedimento.objects.get(id=pedimento_id)
|
||||
else:
|
||||
pedimento = Pedimento.objects.get(pedimento_app=pedimento_app)
|
||||
except Pedimento.DoesNotExist:
|
||||
return Response({'error': 'Pedimento no encontrado'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
task = auditar_pedamentos_incompletos_task.delay(str(pedimento.organizacion_id), str(pedimento.id))
|
||||
logger.info(f"[auditar_incompletos] individual — ped={pedimento.pedimento_app} task={task.id}")
|
||||
|
||||
return Response(
|
||||
{
|
||||
'task_id': task.id,
|
||||
'pedimento_id': str(pedimento.id),
|
||||
'pedimento': pedimento.pedimento_app,
|
||||
'mensaje': 'Análisis individual encolado. Sin modificar nada.',
|
||||
},
|
||||
status=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
|
||||
|
||||
@swagger_auto_schema(
|
||||
method='post',
|
||||
operation_description="Auto-corrige un pedimento específico: extrae campos del XML y actualiza BD + storage. Acepta pedimento_id (UUID) o pedimento_app.",
|
||||
request_body=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
properties={
|
||||
'pedimento_id': openapi.Schema(type=openapi.TYPE_STRING, description='UUID del pedimento'),
|
||||
'pedimento_app': openapi.Schema(type=openapi.TYPE_STRING, description='Número de pedimento (ej: 21-80-3452-1004463)'),
|
||||
},
|
||||
),
|
||||
responses={
|
||||
202: openapi.Response('Tarea de corrección encolada'),
|
||||
400: openapi.Response('Parámetro faltante'),
|
||||
404: openapi.Response('Pedimento no encontrado'),
|
||||
},
|
||||
)
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated, require_permission('auditoria.view')])
|
||||
def auto_corregir_pedamento_endpoint(request):
|
||||
pedimento_id = request.data.get('pedimento_id')
|
||||
pedimento_app = request.data.get('pedimento_app')
|
||||
if not pedimento_id and not pedimento_app:
|
||||
return Response({'error': 'pedimento_id o pedimento_app es requerido'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
if pedimento_id:
|
||||
pedimento = Pedimento.objects.get(id=pedimento_id)
|
||||
else:
|
||||
pedimento = Pedimento.objects.get(pedimento_app=pedimento_app)
|
||||
except Pedimento.DoesNotExist:
|
||||
return Response({'error': 'Pedimento no encontrado'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
task = auto_corregir_pedamentos_task.delay(str(pedimento.organizacion_id), str(pedimento.id))
|
||||
logger.info(f"[auto_corregir] individual — ped={pedimento.pedimento_app} task={task.id}")
|
||||
|
||||
return Response(
|
||||
{
|
||||
'task_id': task.id,
|
||||
'pedimento_id': str(pedimento.id),
|
||||
'pedimento': pedimento.pedimento_app,
|
||||
'mensaje': 'Corrección individual encolada.',
|
||||
},
|
||||
status=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
18
api/datastage/migrations/0012_alter_datastage_archivo.py
Normal file
18
api/datastage/migrations/0012_alter_datastage_archivo.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.3 on 2026-04-20 16:34
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('datastage', '0011_alter_registro502_fecha_pago_real_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='datastage',
|
||||
name='archivo',
|
||||
field=models.CharField(blank=True, max_length=500, null=True),
|
||||
),
|
||||
]
|
||||
26
api/datastage/migrations/0013_registro501_add_timestamps.py
Normal file
26
api/datastage/migrations/0013_registro501_add_timestamps.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from django.db import migrations, models
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('datastage', '0012_alter_datastage_archivo'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# La columna created_at ya existe en la BD (NOT NULL, sin DEFAULT).
|
||||
# Solo actualizamos el estado interno de Django para que auto_now_add
|
||||
# inserte el valor al hacer bulk_create.
|
||||
migrations.SeparateDatabaseAndState(
|
||||
state_operations=[
|
||||
migrations.AddField(
|
||||
model_name='registro501',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
|
||||
preserve_default=False,
|
||||
),
|
||||
],
|
||||
database_operations=[],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,44 @@
|
||||
from django.db import migrations, models
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
"""
|
||||
Las columnas created_at ya existen en la BD como NOT NULL sin DEFAULT.
|
||||
Solo actualizamos el estado interno de Django para que auto_now_add
|
||||
inserte el timestamp al hacer bulk_create.
|
||||
"""
|
||||
|
||||
dependencies = [
|
||||
('datastage', '0013_registro501_add_timestamps'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.SeparateDatabaseAndState(
|
||||
state_operations=[
|
||||
migrations.AddField(model_name='registro502', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||
migrations.AddField(model_name='registro503', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||
migrations.AddField(model_name='registro504', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||
migrations.AddField(model_name='registro505', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||
migrations.AddField(model_name='registro506', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||
migrations.AddField(model_name='registro507', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||
migrations.AddField(model_name='registro508', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||
migrations.AddField(model_name='registro509', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||
migrations.AddField(model_name='registro510', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||
migrations.AddField(model_name='registro511', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||
migrations.AddField(model_name='registro512', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||
migrations.AddField(model_name='registro551', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||
migrations.AddField(model_name='registro552', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||
migrations.AddField(model_name='registro553', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||
migrations.AddField(model_name='registro554', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||
migrations.AddField(model_name='registro555', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||
migrations.AddField(model_name='registro556', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||
migrations.AddField(model_name='registro557', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||
migrations.AddField(model_name='registro558', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||
migrations.AddField(model_name='registrosel', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||
migrations.AddField(model_name='registro701', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||
migrations.AddField(model_name='registro702', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
|
||||
],
|
||||
database_operations=[],
|
||||
),
|
||||
]
|
||||
18
api/notificaciones/migrations/0002_notificacion_datos.py
Normal file
18
api/notificaciones/migrations/0002_notificacion_datos.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.3 on 2026-05-26 13:40
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('notificaciones', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='notificacion',
|
||||
name='datos',
|
||||
field=models.JSONField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -21,6 +21,7 @@ class Notificacion(models.Model):
|
||||
|
||||
|
||||
mensaje = models.TextField(help_text="Mensaje de la notificación")
|
||||
datos = models.JSONField(null=True, blank=True)
|
||||
fecha_envio = models.DateTimeField(blank=True, null=True, help_text="Fecha de envío de la notificación")
|
||||
created_at = models.DateTimeField(auto_now_add=True, help_text="Fecha de creación de la notificación")
|
||||
visto = models.BooleanField(default=False, help_text="Indica si la notificación ha sido vista")
|
||||
|
||||
@@ -16,10 +16,11 @@ class NotificacionSerializer(serializers.ModelSerializer):
|
||||
'tipo',
|
||||
'dirigido',
|
||||
'mensaje',
|
||||
'datos',
|
||||
'fecha_envio',
|
||||
'created_at',
|
||||
'visto'
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'tipo', 'dirigido', 'fecha_envio', 'mensaje']
|
||||
read_only_fields = ['id', 'created_at', 'tipo', 'dirigido', 'fecha_envio', 'mensaje', 'datos']
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from rest_framework import viewsets
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.response import Response
|
||||
|
||||
from .models import Notificacion, TipoNotificacion
|
||||
from .serializers import NotificacionSerializer, TipoNotificacionSerializer
|
||||
@@ -45,3 +47,11 @@ class NotificacionViewSet(viewsets.ModelViewSet):
|
||||
serializer.save()
|
||||
return
|
||||
raise PermissionDenied("No tienes permiso para crear notificaciones para otros usuarios")
|
||||
|
||||
@action(detail=False, methods=['get'], url_path=r'by-task/(?P<task_id>[^/.]+)')
|
||||
def by_task(self, request, task_id=None):
|
||||
"""Recupera la notificación de una tarea de auditoría por su task_id (Celery)."""
|
||||
notif = self.get_queryset().filter(datos__task_id=task_id).first()
|
||||
if not notif:
|
||||
return Response({'detail': 'No encontrada.'}, status=status.HTTP_404_NOT_FOUND)
|
||||
return Response(self.get_serializer(notif).data)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.3 on 2026-05-19 13:45
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('organization', '0002_remove_organizacion_membretado_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='organizacion',
|
||||
name='apply_auto_download',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
25
api/organization/migrations/0004_organizacion_owner.py
Normal file
25
api/organization/migrations/0004_organizacion_owner.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('organization', '0003_organizacion_apply_auto_download'),
|
||||
('cuser', '0005_customuser_rfc_fk_to_m2m'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='organizacion',
|
||||
name='owner',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name='organizaciones_que_administra',
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
]
|
||||
18
api/rbac/migrations/0005_alter_rolepermission_id.py
Normal file
18
api/rbac/migrations/0005_alter_rolepermission_id.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.3 on 2026-05-26 13:40
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('rbac', '0004_auditoria_permissions'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='rolepermission',
|
||||
name='id',
|
||||
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
]
|
||||
18
api/record/migrations/0003_document_vu.py
Normal file
18
api/record/migrations/0003_document_vu.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.3 on 2026-03-06 19:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('record', '0002_fuente_document_fuente'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='vu',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -1321,11 +1321,13 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
|
||||
nombre="Documento General",
|
||||
defaults={'descripcion': "Documento general sin tipo específico"}
|
||||
)
|
||||
|
||||
|
||||
uploaded_documents = []
|
||||
failed_files = []
|
||||
errors = []
|
||||
total_space_used = 0
|
||||
created_count = 0
|
||||
replaced_count = 0
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
@@ -1410,6 +1412,8 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
|
||||
else:
|
||||
raise Exception(f"Error al guardar archivo: {file.name}")
|
||||
document = existing_doc
|
||||
replaced_count += 1
|
||||
was_replaced = True
|
||||
else:
|
||||
# Crear nuevo documento
|
||||
document = Document.objects.create(
|
||||
@@ -1431,6 +1435,8 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
|
||||
else:
|
||||
document.delete()
|
||||
raise Exception(f"Error al guardar archivo: {file.name}")
|
||||
created_count += 1
|
||||
was_replaced = False
|
||||
|
||||
# Actualizar espacio usado
|
||||
espacio_usado_temp += file.size
|
||||
@@ -1441,7 +1447,8 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
|
||||
"filename": file.name,
|
||||
"size": file.size,
|
||||
"extension": extension,
|
||||
"document_type": document.document_type.nombre if document.document_type else None
|
||||
"document_type": document.document_type.nombre if document.document_type else None,
|
||||
"replaced": was_replaced,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
@@ -1463,27 +1470,36 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
|
||||
space_used_mb = round(total_space_used / (1024 * 1024), 2)
|
||||
|
||||
# Preparar respuesta
|
||||
partes = []
|
||||
if created_count:
|
||||
partes.append(f"{created_count} documento(s) creado(s) exitosamente")
|
||||
if replaced_count:
|
||||
partes.append(f"{replaced_count} documento(s) reemplazado(s) exitosamente")
|
||||
mensaje_exito = " y ".join(partes) if partes else "Sin cambios"
|
||||
|
||||
response_data = {
|
||||
"uploaded_count": len(uploaded_documents),
|
||||
"created_count": created_count,
|
||||
"replaced_count": replaced_count,
|
||||
"uploaded_documents": uploaded_documents,
|
||||
"space_used_mb": space_used_mb,
|
||||
"pedimento_id": str(pedimento_id),
|
||||
"document_type": document_type.nombre
|
||||
"document_type": document_type.nombre,
|
||||
}
|
||||
|
||||
|
||||
if failed_files:
|
||||
response_data.update({
|
||||
"message": "Algunos documentos no pudieron ser subidos",
|
||||
"message": f"Algunos documentos no pudieron ser subidos. {mensaje_exito}",
|
||||
"failed_files": failed_files,
|
||||
"errors": errors
|
||||
"errors": errors,
|
||||
})
|
||||
response_status = status.HTTP_207_MULTI_STATUS
|
||||
else:
|
||||
response_data["message"] = "Documentos subidos exitosamente"
|
||||
response_data["message"] = mensaje_exito
|
||||
response_status = status.HTTP_201_CREATED
|
||||
|
||||
|
||||
return Response(response_data, status=response_status)
|
||||
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='bulk-upload-vu', parser_classes=[MultiPartParser])
|
||||
def bulk_upload_vu(self, request):
|
||||
"""
|
||||
|
||||
18
api/reports/migrations/0002_reportdocument_report_type.py
Normal file
18
api/reports/migrations/0002_reportdocument_report_type.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.3 on 2025-11-21 14:40
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('reports', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='reportdocument',
|
||||
name='report_type',
|
||||
field=models.CharField(choices=[('cumplimiento', 'cumplimiento'), ('control_pedimento', 'control_pedimento')], default='cumplimiento', max_length=30),
|
||||
),
|
||||
]
|
||||
18
api/reports/migrations/0003_alter_reportdocument_file.py
Normal file
18
api/reports/migrations/0003_alter_reportdocument_file.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.3 on 2026-04-21 22:53
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('reports', '0002_reportdocument_report_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='reportdocument',
|
||||
name='file',
|
||||
field=models.CharField(blank=True, max_length=500, null=True),
|
||||
),
|
||||
]
|
||||
23
api/vucem/migrations/0012_alter_vucem_cer_alter_vucem_key.py
Normal file
23
api/vucem/migrations/0012_alter_vucem_cer_alter_vucem_key.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.3 on 2026-04-21 14:58
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vucem', '0011_alter_credencialesimportador_rfc'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='vucem',
|
||||
name='cer',
|
||||
field=models.CharField(blank=True, help_text='Certificado de VUCEM', max_length=500, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='vucem',
|
||||
name='key',
|
||||
field=models.CharField(blank=True, help_text='Llave privada de VUCEM', max_length=500, null=True),
|
||||
),
|
||||
]
|
||||
42
core/redis_events.py
Normal file
42
core/redis_events.py
Normal file
@@ -0,0 +1,42 @@
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CHANNEL_PREFIX = "audit_task:"
|
||||
STATE_PREFIX = "audit_task_state:"
|
||||
STATE_TTL = 7200 # 2 horas
|
||||
|
||||
|
||||
def _get_client():
|
||||
import redis
|
||||
return redis.Redis(
|
||||
host=os.getenv("REDIS_HOST", "localhost"),
|
||||
port=int(os.getenv("REDIS_PORT", 6379)),
|
||||
db=int(os.getenv("REDIS_DB", 0)),
|
||||
decode_responses=True,
|
||||
socket_connect_timeout=2,
|
||||
socket_timeout=2,
|
||||
)
|
||||
|
||||
|
||||
def publish_task_event(task_id: str, status: str, message: str = "", resultado: dict = None, progress: int = None):
|
||||
"""
|
||||
Publica un evento de progreso de tarea en Redis Pub/Sub.
|
||||
El microservicio SSE usa el mismo canal para streamear al frontend.
|
||||
"""
|
||||
payload: dict = {"task_id": task_id, "status": status, "message": message}
|
||||
if resultado is not None:
|
||||
payload["resultado"] = resultado
|
||||
if progress is not None:
|
||||
payload["progress"] = progress
|
||||
|
||||
try:
|
||||
client = _get_client()
|
||||
serialized = json.dumps(payload)
|
||||
client.publish(f"{CHANNEL_PREFIX}{task_id}", serialized)
|
||||
client.setex(f"{STATE_PREFIX}{task_id}", STATE_TTL, serialized)
|
||||
client.close()
|
||||
except Exception as exc:
|
||||
logger.error(f"[redis_events] Error publicando evento para tarea {task_id}: {exc}")
|
||||
Reference in New Issue
Block a user