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

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

View File

@@ -43,6 +43,7 @@ from django.core.files.storage import default_storage
from django.conf import settings
import requests
import re
import xml.etree.ElementTree as ET
from mixins.filtrado_organizacion import DocumentosFiltradosMixin
@@ -122,9 +123,69 @@ def obtener_tipo_documento_por_patron(nombre_archivo, organizacion, pedimento_id
raise ValidationError({
"error": f"El tipo de documento '{descripcion}' no existe. Por favor, créelo primero."
})
return None
# Apartado "Pedimento" del detalle: los XML se clasifican por contenido (no por nombre de
# archivo) usando los namespaces de las respuestas SOAP de VUCEM que deposita el microservicio,
# y se renombran a la nomenclatura canónica vu_PC_/vu_RM_{pedimento_app}.xml (tipos 2 y 3,
# los mismos que asigna el microservicio y que filtra PedimentoDocumentViewSet).
NS_PEDIMENTO_COMPLETO = 'http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/consultarpedimentocompleto'
NS_REMESAS = 'http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/consultarremesas'
PEDIMENTO_TAB_TIPOS = {
NS_PEDIMENTO_COMPLETO: (2, 'vu_PC'),
NS_REMESAS: (3, 'vu_RM'),
}
def clasificar_xml_apartado_pedimento(file, pedimento):
"""Clasifica un XML subido al apartado Pedimento como Pedimento Completo o Remesa.
Devuelve (document_type_id, nombre_canonico). Lanza ValueError con un mensaje
apto para el usuario si el archivo no es XML, no clasifica o pertenece a otro pedimento.
"""
extension = file.name.split('.')[-1].lower() if '.' in file.name else ''
if extension != 'xml':
raise ValueError(f"'{file.name}': en este apartado solo se aceptan archivos XML")
try:
contenido = file.read()
file.seek(0)
root = ET.fromstring(contenido)
except ET.ParseError:
raise ValueError(f"'{file.name}': el archivo no es un XML válido")
tipo_encontrado = None
for elemento in root.iter():
for ns, mapeo in PEDIMENTO_TAB_TIPOS.items():
if isinstance(elemento.tag, str) and elemento.tag.startswith('{' + ns + '}'):
tipo_encontrado = (ns,) + mapeo
break
if tipo_encontrado:
break
if not tipo_encontrado:
raise ValueError(
f"'{file.name}': el XML no corresponde a un Pedimento Completo ni a una Remesa de VUCEM"
)
ns, type_id, prefijo = tipo_encontrado
# Validar pertenencia: el número de pedimento del XML debe coincidir con el actual.
# La respuesta de remesas no incluye el número, así que solo aplica a pedimento completo.
if ns == NS_PEDIMENTO_COMPLETO:
nodo = root.find(f'.//{{{ns}}}pedimento/{{{ns}}}pedimento')
numero_xml = re.sub(r'\D', '', nodo.text or '') if nodo is not None else ''
numero_actual = re.sub(r'\D', '', pedimento.pedimento or '')
if numero_xml and numero_actual and numero_xml != numero_actual:
raise ValueError(
f"'{file.name}': el XML corresponde al pedimento {nodo.text.strip()}, "
f"no al pedimento actual ({pedimento.pedimento_app})"
)
return type_id, f"{prefijo}_{pedimento.pedimento_app}.xml"
class CustomPagination(PageNumberPagination):
"""
@@ -191,7 +252,7 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
modulo_efc = self.request.query_params.get('modulo')
if modulo_efc:
if modulo_efc == 'expedientes-detalle-pedimentos':
queryset = queryset.exclude(document_type_id__in=['1','2','3','4','5','6','7','8','9','10','25','23','21','19','17','15','13','16'])
queryset = queryset.exclude(document_type_id__in=['1','2','3','4','5','6','7','8','9','10','25','23','21','19','17','15','13','14','16','18','20','22','24','26'])
# Filtro personalizado por document_type
# document_type = self.request.query_params.get('document_type')
# if document_type:
@@ -1133,6 +1194,10 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
defaults={'descripcion': "Documento general sin tipo específico"}
)
# Apartado del detalle desde el que se sube; 'pedimento' activa la
# clasificación del XML por contenido y el renombrado canónico
tab_seccion = request.data.get('tab_seccion')
uploaded_documents = []
failed_files = []
errors = []
@@ -1188,6 +1253,27 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
errors.append("Archivo sin nombre detectado")
continue
# Tipo por archivo: en el apartado Pedimento se clasifica el XML por
# contenido y se renombra a la nomenclatura canónica vu_PC_/vu_RM_
file_document_type = document_type
tipo_explicito = bool(document_type_id_param)
if tab_seccion == 'pedimento':
try:
type_id, nombre_canonico = clasificar_xml_apartado_pedimento(file, pedimento)
file_document_type = DocumentType.objects.get(id=type_id)
except ValueError as e:
failed_files.append(file.name)
errors.append(str(e))
continue
except DocumentType.DoesNotExist:
failed_files.append(file.name)
errors.append(
f"'{file.name}': el tipo de documento requerido no existe en el catálogo. Por favor, créelo primero."
)
continue
file.name = nombre_canonico
tipo_explicito = True
# Obtener extensión del archivo
extension = file.name.split('.')[-1].lower() if '.' in file.name else ''
@@ -1195,15 +1281,25 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
# storage_service agrega un sufijo UUID de 8 chars al guardar, hay que ignorarlo.
new_name_base = re.sub(r'_[a-zA-Z0-9]{8}$', '', os.path.splitext(file.name)[0]).lower().strip('_')
existing_doc = None
for doc in existing_docs:
if doc.archivo:
doc_basename = os.path.basename(doc.archivo.name)
doc_base = re.sub(r'_[a-zA-Z0-9]{8}$', '', os.path.splitext(doc_basename)[0]).lower().strip('_')
doc_ext = (doc.extension or '').lower()
if new_name_base == doc_base and extension == doc_ext:
# En el apartado Pedimento el reemplazo es por tipo: solo debe existir
# un Pedimento Completo y una Remesa por pedimento
if tab_seccion == 'pedimento':
for doc in existing_docs:
if doc.document_type_id == file_document_type.id:
existing_doc = doc
break
if existing_doc is None:
for doc in existing_docs:
if doc.archivo:
doc_basename = os.path.basename(doc.archivo.name)
doc_base = re.sub(r'_[a-zA-Z0-9]{8}$', '', os.path.splitext(doc_basename)[0]).lower().strip('_')
doc_ext = (doc.extension or '').lower()
if new_name_base == doc_base and extension == doc_ext:
existing_doc = doc
break
if existing_doc:
# Reemplazar archivo del documento existente
if existing_doc.archivo:
@@ -1218,7 +1314,10 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
existing_doc.archivo = ruta
existing_doc.size = file.size
existing_doc.extension = extension
existing_doc.document_type = document_type
# Conservar el tipo del documento existente salvo que el
# request lo defina explícitamente (no degradar docs VU)
if tipo_explicito:
existing_doc.document_type = file_document_type
existing_doc.save()
else:
raise Exception(f"Error al guardar archivo: {file.name}")
@@ -1230,7 +1329,7 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
document = Document.objects.create(
organizacion=organizacion,
pedimento_id=pedimento_id,
document_type=document_type,
document_type=file_document_type,
size=file.size,
extension=extension
)
@@ -1248,6 +1347,8 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
raise Exception(f"Error al guardar archivo: {file.name}")
created_count += 1
was_replaced = False
# Visible para detección de duplicados de archivos posteriores del mismo lote
existing_docs.append(document)
# Actualizar espacio usado
espacio_usado_temp += file.size
@@ -1299,8 +1400,12 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
}
if failed_files:
if uploaded_documents:
mensaje_fallo = f"Algunos documentos no pudieron ser subidos. {mensaje_exito}"
else:
mensaje_fallo = "No fue posible subir ningún documento"
response_data.update({
"message": f"Algunos documentos no pudieron ser subidos. {mensaje_exito}",
"message": mensaje_fallo,
"failed_files": failed_files,
"errors": errors,
})
@@ -1640,7 +1745,7 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
if not request.user.is_authenticated:
return Response({"error": "Usuario no autenticado"}, status=status.HTTP_401_UNAUTHORIZED)
from api.customs.models import Pedimento as PedimentoModel, Partida, Cove, EDocument
from api.customs.models import Pedimento as PedimentoModel, Partida, Cove, EDocument, EstadoDescarga
try:
pedimento = PedimentoModel.objects.get(id=pedimento_id)
except PedimentoModel.DoesNotExist:
@@ -1788,6 +1893,12 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
)
if ruta:
# Confirmar que el archivo quedó físicamente en storage antes
# de contar la sección como subida (T2026-05-027): nunca marcar
# descargado sin archivo verificado
if not storage_service.file_exists(ruta):
document.delete()
raise Exception(f"El archivo no se encuentra en storage tras guardarlo: {file.name}")
document.archivo = ruta
document.save()
else:
@@ -1811,7 +1922,8 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
errors.append(f"Error al procesar {file.name}: {str(e)}")
continue
# Actualizar flags de descarga según secciones subidas exitosamente
# Actualizar estados de descarga según secciones subidas y verificadas
# en storage; el modelo deriva los booleanos legados del estado
if tab_seccion == 'partida':
if uploaded_secciones:
expediente_obj.descargado = True
@@ -1819,21 +1931,21 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
elif tab_seccion == 'cove':
update_fields = []
if 'general' in uploaded_secciones:
expediente_obj.cove_descargado = True
update_fields.append('cove_descargado')
expediente_obj.cove_estado = EstadoDescarga.DESCARGADO
update_fields.append('cove_estado')
if 'acuse' in uploaded_secciones:
expediente_obj.acuse_cove_descargado = True
update_fields.append('acuse_cove_descargado')
expediente_obj.acuse_cove_estado = EstadoDescarga.DESCARGADO
update_fields.append('acuse_cove_estado')
if update_fields:
expediente_obj.save(update_fields=update_fields)
elif tab_seccion == 'edoc':
update_fields = []
if 'general' in uploaded_secciones:
expediente_obj.edocument_descargado = True
update_fields.append('edocument_descargado')
expediente_obj.edocument_estado = EstadoDescarga.DESCARGADO
update_fields.append('edocument_estado')
if 'acuse' in uploaded_secciones:
expediente_obj.acuse_descargado = True
update_fields.append('acuse_descargado')
expediente_obj.acuse_estado = EstadoDescarga.DESCARGADO
update_fields.append('acuse_estado')
if update_fields:
expediente_obj.save(update_fields=update_fields)