fix/de los tickets T2026-05-027, T2025-09-004 y T2025-09-056
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user