package com.sapolar.document; import com.sapolar.common.exception.BadRequestException; import com.sapolar.common.exception.ResourceNotFoundException; import com.sapolar.config.FileStorageConfig; import lombok.RequiredArgsConstructor; import org.springframework.core.io.Resource; import org.springframework.core.io.UrlResource; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.net.MalformedURLException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardCopyOption; import java.util.List; @Service @RequiredArgsConstructor public class DocumentService { private final FileStorageConfig fileStorageConfig; private final DocumentRepository documentRepository; private final DocumentEntityRepository documentEntityRepository; private final DocumentTypeRepository documentTypeRepository; private final DocumentTypeEntityAllowedRepository documentTypeEntityAllowedRepository; @Transactional public Document uploadFile(MultipartFile file, String entityType, Long entityId, Integer documentTypeId, String description, Long uploaderId) { String originalName = StringUtils.cleanPath(file.getOriginalFilename()); if (originalName.isBlank()) { throw new BadRequestException("Nombre de archivo no válido"); } // Validar que el tipo de documento está permitido para esta entidad if (!isDocumentTypeAllowedForEntity(documentTypeId, entityType)) { throw new BadRequestException( "El tipo de documento no está permitido para entidades de tipo " + entityType); } String extension = ""; int dotIndex = originalName.lastIndexOf('.'); if (dotIndex > 0) { extension = originalName.substring(dotIndex); } String storedName = java.util.UUID.randomUUID().toString() + extension; // Guardar archivo en disco (usando la primera entidad para la ruta) Path uploadDir = Paths.get(fileStorageConfig.getPath()) .resolve(entityType.toLowerCase()) .resolve(entityId.toString()); try { Files.createDirectories(uploadDir); Path targetPath = uploadDir.resolve(storedName); Files.copy(file.getInputStream(), targetPath, StandardCopyOption.REPLACE_EXISTING); } catch (IOException e) { throw new RuntimeException("Error al subir el archivo: " + originalName, e); } // Crear documento Document document = new Document(); DocumentType docType = new DocumentType(); docType.setId(documentTypeId); document.setDocumentType(docType); document.setOriginalName(originalName); document.setStoredName(storedName); document.setMimeType(file.getContentType()); document.setFileSize(file.getSize()); document.setDescription(description); Document savedDoc = documentRepository.save(document); // Crear asociación con la entidad primaria savedDoc.addEntity(entityType, entityId); return documentRepository.save(savedDoc); } /** * Valida si un tipo de documento está permitido para una entidad. * Si la relación no existe en la tabla de permitidos, se permite por defecto * (comportamiento backward compatible). */ public boolean isDocumentTypeAllowedForEntity(Integer documentTypeId, String entityType) { // Si no existe la tabla de permitidos, permitir (backward compatible) if (!documentTypeEntityAllowedRepository.existsByDocumentTypeIdAndEntityType(documentTypeId, entityType)) { // Verificar si existe alguna configuración para este tipo de documento List configs = documentTypeEntityAllowedRepository .findByDocumentTypeIdAndEntityType(documentTypeId, entityType) .stream().toList(); if (configs.isEmpty()) { // No hay configuración, verificar si hay alguna para este document type long totalConfigs = documentTypeEntityAllowedRepository.findAll().stream() .filter(c -> c.getDocumentType().getId().equals(documentTypeId)) .count(); // Si no hay ninguna configuración para este tipo de documento, permitir // (es un tipo antiguo sin restricciones) if (totalConfigs == 0) { return true; } // Hay configuraciones pero ninguna para esta entidad específica return false; } } return documentTypeEntityAllowedRepository.existsByDocumentTypeIdAndEntityType(documentTypeId, entityType); } public DocumentDownloadResult downloadFile(Long documentId) { Document document = documentRepository.findById(documentId) .orElseThrow(() -> new ResourceNotFoundException("Documento", documentId)); // Usar la primera entidad para determinar la ruta del archivo if (document.getEntities().isEmpty()) { throw new ResourceNotFoundException("Documento", documentId); } String entityType = document.getEntities().iterator().next().getEntityType(); Long entityId = document.getEntities().iterator().next().getEntityId(); try { Path filePath = Paths.get(fileStorageConfig.getPath()) .resolve(entityType.toLowerCase()) .resolve(entityId.toString()) .resolve(document.getStoredName()); Resource resource = new UrlResource(filePath.toUri()); if (resource.exists() && resource.isReadable()) { return new DocumentDownloadResult(resource, document.getMimeType(), document.getOriginalName()); } throw new RuntimeException("No se puede leer el archivo: " + document.getOriginalName()); } catch (MalformedURLException e) { throw new RuntimeException("Error al acceder al archivo", e); } } public List getDocumentsForEntity(String entityType, Long entityId) { return documentRepository.findByEntityTypeAndEntityId(entityType, entityId); } public List searchDocuments(String entityType, Long entityId, Integer documentTypeId, String originalName) { return documentRepository.searchDocuments(entityType, entityId, documentTypeId, originalName); } @Transactional public void deleteDocument(Long documentId) { Document document = documentRepository.findById(documentId) .orElseThrow(() -> new ResourceNotFoundException("Documento", documentId)); // Eliminar archivo físico if (!document.getEntities().isEmpty()) { DocumentEntity firstEntity = document.getEntities().iterator().next(); try { Path filePath = Paths.get(fileStorageConfig.getPath()) .resolve(firstEntity.getEntityType().toLowerCase()) .resolve(firstEntity.getEntityId().toString()) .resolve(document.getStoredName()); Files.deleteIfExists(filePath); } catch (IOException e) { // Log error but continue with DB deletion } } documentRepository.delete(document); } @Transactional public Document addEntityAssociation(Long documentId, String entityType, Long entityId) { Document document = documentRepository.findById(documentId) .orElseThrow(() -> new ResourceNotFoundException("Documento", documentId)); // Validar que el tipo de documento está permitido para esta entidad if (!isDocumentTypeAllowedForEntity(document.getDocumentType().getId(), entityType)) { throw new BadRequestException( "El tipo de documento no está permitido para entidades de tipo " + entityType); } // Verificar que no exista ya if (documentEntityRepository.findByDocumentIdAndEntityTypeAndEntityId(documentId, entityType, entityId).isPresent()) { throw new BadRequestException("El documento ya está asociado a esta entidad"); } document.addEntity(entityType, entityId); return documentRepository.save(document); } @Transactional public void removeEntityAssociation(Long documentId, String entityType, Long entityId) { DocumentEntity entity = documentEntityRepository .findByDocumentIdAndEntityTypeAndEntityId(documentId, entityType, entityId) .orElseThrow(() -> new ResourceNotFoundException("Asociación no encontrada")); // Verificar que no sea la última asociación (el documento debe tener al menos una) Document document = entity.getDocument(); if (document.getEntities().size() <= 1) { throw new BadRequestException("No se puede eliminar la última asociación. Elimine el documento completo."); } document.removeEntity(entityType, entityId); documentEntityRepository.delete(entity); } public List getDocumentEntities(Long documentId) { Document document = documentRepository.findById(documentId) .orElseThrow(() -> new ResourceNotFoundException("Documento", documentId)); return List.copyOf(document.getEntities()); } public List getDocumentTypes() { return documentTypeRepository.findAll(); } /** * Obtiene los tipos de documento permitidos para una entidad específica. * Incluye información sobre si es obligatorio o no. */ public List getDocumentTypesForEntity(String entityType) { List allowed = documentTypeEntityAllowedRepository .findAllowedTypesForEntity(entityType); return allowed.stream() .map(dtea -> new DocumentTypeForEntityDto( dtea.getDocumentType().getId(), dtea.getDocumentType().getName(), dtea.getEntityType(), dtea.getCanUpload(), dtea.getMustHave(), dtea.getDescription())) .toList(); } // Record para devolver el archivo y su información de cabecera public record DocumentDownloadResult(Resource resource, String mimeType, String filename) {} }