ProductService.java
package com.albertsilva.dev.asjcatalog.service;
import java.util.Arrays;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import com.albertsilva.dev.asjcatalog.domain.catalog.Category;
import com.albertsilva.dev.asjcatalog.domain.catalog.Product;
import com.albertsilva.dev.asjcatalog.dto.product.request.ProductCreateRequest;
import com.albertsilva.dev.asjcatalog.dto.product.request.ProductUpdateRequest;
import com.albertsilva.dev.asjcatalog.dto.product.response.ProductDetailsResponse;
import com.albertsilva.dev.asjcatalog.dto.product.response.ProductResponse;
import com.albertsilva.dev.asjcatalog.mapper.product.ProductMapper;
import com.albertsilva.dev.asjcatalog.projection.ProductProjection;
import com.albertsilva.dev.asjcatalog.repository.CategoryRepository;
import com.albertsilva.dev.asjcatalog.repository.ProductRepository;
import com.albertsilva.dev.asjcatalog.service.exception.DatabaseException;
import com.albertsilva.dev.asjcatalog.service.exception.ResourceNotFoundException;
import com.albertsilva.dev.asjcatalog.util.IdentifiableUtils;
import jakarta.persistence.EntityNotFoundException;
/**
* Serviço de aplicação dos produtos ({@link Product}): listagens paginadas,
* consulta, criação, atualização, ativação/desativação e exclusão, incluindo o
* vínculo do produto com categorias.
*
* <p>
* <b>Dependências:</b> {@code ProductRepository}, {@code CategoryRepository}
* (para resolver as categorias de um produto), {@code ProductMapper}
* (conversão entre DTOs, entidade e respostas) e {@code IdentifiableUtils}.
* Este service não chama validators: a validação dos DTOs de entrada
* ({@code @Valid}), que inclui a unicidade do nome e a existência das
* categorias, ocorre antes, na camada web.
* </p>
*
* <p>
* <b>Transações:</b> todos os métodos públicos são transacionais (leituras
* com {@code readOnly = true}, escritas com transação de escrita). Os métodos
* privados não têm anotação e executam dentro da transação do método público
* que os chamou. Erros não tratados ({@code RuntimeException}) revertem a
* transação.
* </p>
*
* <p>
* <b>Autorização:</b> não é feita aqui. As regras de acesso ficam nos
* controllers ({@code @PreAuthorize}) e na configuração de segurança.
* </p>
*
* <p>
* <b>Exceções:</b> {@link ResourceNotFoundException} (produto ou categoria
* inexistente) e {@link DatabaseException} (exclusão). A conversão em resposta
* HTTP é feita por {@code ControllerExceptionHandler}.
* </p>
*
* <p>
* <b>Comportamento atual do indicador {@code active}:</b> é apenas gravado por
* {@link #create}, {@link #activate(Long)} e {@link #deactivate(Long)};
* nenhuma consulta ou regra deste service o utiliza para filtrar ou bloquear
* produtos.
* </p>
*/
@Service
public class ProductService {
private static final Logger logger = LoggerFactory.getLogger(ProductService.class);
private final ProductRepository productRepository;
private final CategoryRepository categoryRepository;
private final ProductMapper productMapper;
/**
* Constrói o serviço de produtos com suas dependências principais.
*
* @param productRepository repositório de produtos
* @param categoryRepository repositório de categorias
* @param productMapper responsável pela conversão entre DTOs e entidades
*/
public ProductService(ProductRepository productRepository, CategoryRepository categoryRepository,
ProductMapper productMapper) {
this.productRepository = productRepository;
this.categoryRepository = categoryRepository;
this.productMapper = productMapper;
}
/**
* Lista produtos com filtro por nome e por categorias e com paginação; é a
* consulta usada pela listagem HTTP de produtos.
*
* <p>
* <b>Sequência:</b>
* </p>
* <ol>
* <li>converte {@code categoryId} em lista de ids: {@code "0"} resulta em
* lista vazia (é o valor padrão do controller); qualquer outro valor é
* dividido por vírgula e cada parte é convertida com
* {@code Long.parseLong}</li>
* <li>{@code searchProducts} (SQL nativo) devolve uma página de
* {@link ProductProjection} (id e nome) já filtrada, ordenada e paginada</li>
* <li>coleta os ids da página e chama {@code searchProductsWithCategories}
* (JPQL com {@code JOIN FETCH}), que carrega os produtos com as
* categorias</li>
* <li>{@code IdentifiableUtils.reorderByReference} recoloca os produtos na
* ordem da página; ids sem produto correspondente são descartados</li>
* <li>converte cada produto com {@code productMapper.toResponse} e monta um
* {@code PageImpl} com o total informado pela consulta nativa</li>
* </ol>
*
* <p>
* <b>Comportamento observado no código:</b>
* </p>
* <ul>
* <li>{@code name} recebe {@code trim} antes da consulta, como em
* {@code CategoryService.search}; nulo, vazio ou só com espaços vira
* {@code ""} (sem filtro por nome); o controller usa {@code ""} por
* padrão</li>
* <li>não há filtro por {@code active}: produtos inativos são listados</li>
* <li>só aparecem produtos com ao menos uma categoria (a consulta nativa usa
* {@code INNER JOIN})</li>
* <li>{@code searchProductsWithCategories} é chamado mesmo quando a página
* não tem ids (lista vazia); não há tratamento específico desse caso</li>
* <li>{@code categoryId} nulo causa {@link NullPointerException} e valor não
* numérico causa {@link NumberFormatException}; nenhuma das duas é tratada
* aqui</li>
* <li>a ordenação do {@code Pageable} é aplicada pela consulta nativa, sobre
* as colunas {@code id} e {@code name}</li>
* </ul>
*
* @param name trecho do nome do produto
* @param categoryId ids de categorias separados por vírgula, ou {@code "0"}
* para não filtrar por categoria
* @param pageable página, tamanho e ordenação
* @return página de {@link ProductResponse}, com as categorias de cada
* produto
*/
@Transactional(readOnly = true)
public Page<ProductResponse> findAllPaged(String name, String categoryId, Pageable pageable) {
List<Long> categoryIds = Arrays.asList();
if (!"0".equals(categoryId)) {
categoryIds = Arrays.asList(categoryId.split(",")).stream().map(Long::parseLong).toList();
}
String filter = StringUtils.hasText(name) ? name.trim() : "";
Page<ProductProjection> page = productRepository.searchProducts(categoryIds, filter, pageable);
List<Long> productsIds = page.map(ProductProjection::getId).toList();
List<Product> products = productRepository.searchProductsWithCategories(productsIds);
products = IdentifiableUtils.reorderByReference(products, page.getContent());
List<ProductResponse> responses = products.stream().map(productMapper::toResponse).toList();
return new PageImpl<>(responses, pageable, page.getTotalElements());
}
/**
* Retorna os detalhes de um produto, incluindo suas categorias.
*
* <p>
* Carrega o produto com {@code findById} e converte com
* {@code toDetailsResponse}; as categorias são carregadas sob demanda durante
* a conversão, dentro da transação de leitura.
* </p>
*
* @param id identificador do produto
* @return detalhes do produto
* @throws ResourceNotFoundException ({@code error.product.notFound}) se o
* produto não existir
*/
@Transactional(readOnly = true)
public ProductDetailsResponse findById(Long id) {
return productMapper.toDetailsResponse(findEntityById(id));
}
/**
* Cria um produto ativo e o vincula às categorias informadas.
*
* <p>
* Sequência: o mapper converte o DTO (copia nome, descrição, preço e URL da
* imagem); o produto é marcado como ativo; as categorias são resolvidas por
* {@code syncCategories}; o produto é salvo. As datas são preenchidas pelos
* callbacks JPA. O campo {@code date} do request não é usado.
* </p>
*
* <p>
* <b>Regras fora deste método:</b> a unicidade do nome e a existência das
* categorias são validadas antes, pelo validator {@code ProductCreateValid}.
* O banco também impõe nome único; uma violação ocorreria na gravação e
* não é tratada aqui.
* </p>
*
* @param productCreateRequest dados para criação do produto
* @return produto criado, com suas categorias
* @throws ResourceNotFoundException ({@code error.product.categories.notFound})
* se alguma categoria não existir
*/
@Transactional
public ProductResponse create(ProductCreateRequest productCreateRequest) {
logger.debug("Inserindo novo produto - dados: {}", productCreateRequest);
Product entity = productMapper.toEntity(productCreateRequest);
entity.setActive(true);
syncCategories(entity, productCreateRequest.categoryIds());
entity = productRepository.save(entity);
logger.info("Produto criado com sucesso. id: {}", entity.getId());
return productMapper.toResponse(entity);
}
/**
* Atualiza um produto existente e, se informado, substitui suas categorias.
*
* <p>
* Usa {@code getReferenceById}, que devolve uma referência preguiçosa: a
* existência do produto só é verificada quando o proxy é acessado (pelo
* mapper, por {@code syncCategories} ou por {@code save}), sempre dentro do
* bloco {@code try}. Se o produto não existir, o
* {@code EntityNotFoundException} da JPA é convertido em
* {@link ResourceNotFoundException}.
* </p>
*
* <ul>
* <li>campos nulos do DTO não sobrescrevem os valores atuais (regra do
* {@code ProductMapper.updateEntity})</li>
* <li>{@code categoryIds} diferente de nulo: as categorias são substituídas
* ({@code syncCategories}); uma lista vazia remove todas (o DTO exige lista
* não vazia com {@code @NotEmpty}, mas este service não)</li>
* <li>{@code categoryIds} nulo: as categorias atuais são mantidas</li>
* </ul>
*
* <p>
* A unicidade do nome é validada antes, por {@code ProductUpdateValid}. O
* {@code save} é chamado mesmo sobre a entidade já gerenciada.
* </p>
*
* @param id identificador do produto
* @param dto dados para atualização
* @return produto atualizado
* @throws ResourceNotFoundException se o produto ({@code error.product.notFound})
* ou alguma categoria
* ({@code error.product.categories.notFound})
* não existir
*/
@Transactional
public ProductResponse update(Long id, ProductUpdateRequest dto) {
logger.debug("Atualizando produto. id: {}", id);
try {
Product entity = productRepository.getReferenceById(id);
productMapper.updateEntity(dto, entity);
if (dto.categoryIds() != null) {
syncCategories(entity, dto.categoryIds());
logger.debug("Categorias do produto atualizadas. id: {}", id);
}
entity = productRepository.save(entity);
logger.info("Serviço Produto atualizado com sucesso. id: {}", id);
return productMapper.toResponse(entity);
} catch (EntityNotFoundException e) {
logger.warn("Falha ao atualizar produto. Produto não encontrado. id: {}", id);
throw new ResourceNotFoundException("error.product.notFound");
}
}
/**
* Marca o produto como ativo. Operação idempotente: se já estiver ativo,
* nada é alterado.
*
* <p>
* Não chama {@code save}: a alteração da entidade gerenciada é gravada no
* commit da transação. O indicador é apenas armazenado; nenhuma listagem ou
* regra deste service o considera.
* </p>
*
* @param id identificador do produto
* @throws ResourceNotFoundException se o produto não existir
*/
@Transactional
public void activate(Long id) {
changeStatus(id, true);
}
/**
* Marca o produto como inativo. Operação idempotente: se já estiver inativo,
* nada é alterado.
*
* <p>
* Não chama {@code save}: a alteração da entidade gerenciada é gravada no
* commit da transação. O indicador é apenas armazenado; nenhuma listagem ou
* regra deste service o considera (produtos inativos continuam aparecendo em
* {@code findAllPaged}).
* </p>
*
* @param id identificador do produto
* @throws ResourceNotFoundException se o produto não existir
*/
@Transactional
public void deactivate(Long id) {
changeStatus(id, false);
}
/**
* Remove fisicamente um produto.
*
* <p>
* Carrega o produto com {@code findById} (404 se não existir), remove-o com
* {@code delete} e converte {@code DataIntegrityViolationException} em
* {@link DatabaseException}. As linhas de {@code tb_product_category} desse
* produto são removidas pela própria JPA, pois {@code Product} é o lado dono
* do relacionamento.
* </p>
*
* <p>
* Atenção: o {@code try/catch} envolve apenas a chamada a {@code delete}.
* Como a remoção costuma ser sincronizada com o banco no commit, depois do
* retorno do método, uma violação de integridade nesse momento pode não
* passar por este {@code catch}.
* </p>
*
* @param id identificador do produto
* @throws ResourceNotFoundException se o produto não existir
* @throws DatabaseException se a exclusão violar a integridade dos
* dados (quando detectada dentro do
* método)
*/
@Transactional
public void delete(Long id) {
logger.debug("Deletando produto. id: {}", id);
Product entity = findEntityById(id);
try {
productRepository.delete(entity);
logger.info("Produto deletado com sucesso. id: {}", id);
} catch (DataIntegrityViolationException e) {
logger.error("Erro de integridade ao deletar produto. id: {}", id);
throw new DatabaseException("error.database.product.relatedEntities");
}
}
/**
* Busca uma entidade {@link Product} por ID.
*
* <p>
* Realiza consulta imediata e lança exceção caso não encontre a entidade.
* </p>
*
* @param id identificador do produto
* @return entidade encontrada
* @throws ResourceNotFoundException caso o produto não exista
*/
private Product findEntityById(Long id) {
logger.debug("Buscando produto por id: {}", id);
return productRepository.findById(id).orElseThrow(() -> {
logger.warn("Produto não encontrado. id: {}", id);
return new ResourceNotFoundException("error.product.notFound");
});
}
/**
* Substitui as categorias do produto pelas informadas.
*
* <ol>
* <li>limpa o conjunto atual de categorias ({@code clear()}), antes de
* qualquer validação</li>
* <li>se a lista for nula ou vazia, termina: o produto fica sem
* categorias</li>
* <li>busca todas as categorias com {@code findAllById} (uma consulta)</li>
* <li>se a quantidade encontrada for diferente da quantidade de ids
* recebidos, lança {@link ResourceNotFoundException}</li>
* </ol>
*
* <p>
* A verificação é por quantidade: {@code findAllById} tende a devolver cada
* categoria uma única vez, então ids repetidos na lista resultariam em
* exceção mesmo com todas as categorias existentes. Quando a exceção é
* lançada, a transação é revertida, e o {@code clear()} não é persistido.
* </p>
*
* @param entity produto que receberá as categorias
* @param categoryIds ids das categorias
* @throws ResourceNotFoundException se alguma categoria não existir
* ({@code error.product.categories.notFound})
*/
private void syncCategories(Product entity, List<Long> categoryIds) {
entity.getCategories().clear();
if (categoryIds == null || categoryIds.isEmpty()) {
logger.debug("Nenhuma categoria fornecida para mapear ao produto. id: {}", entity.getId());
return;
}
List<Category> categories = categoryRepository.findAllById(categoryIds);
if (categories.size() != categoryIds.size()) {
logger.warn("Uma ou mais categorias não foram encontradas. produtoId: {}", entity.getId());
throw new ResourceNotFoundException("error.product.categories.notFound");
}
entity.getCategories().addAll(categories);
logger.debug("Categorias mapeadas ao produto. produtoId: {}, total: {}", entity.getId(), categories.size());
}
/**
* Define o indicador {@code active} do produto, se for diferente do atual.
*
* <p>
* Carrega o produto com {@code findById}; se o valor já for o desejado,
* apenas registra em log e retorna. Não chama {@code save}: a mudança é
* gravada no commit.
* </p>
*
* @param id identificador do produto
* @param active valor desejado ({@code true} = ativo)
* @throws ResourceNotFoundException se o produto não existir
*/
private void changeStatus(Long id, boolean active) {
Product entity = findEntityById(id);
if (entity.isActive() == active) {
logger.debug("Status já definido | id={} | active={}", id, active);
return;
}
entity.setActive(active);
logger.info("Status alterado | id={} | active={}", id, active);
}
}