Com a persistência pronta (Módulo 01), este módulo implementa a regra de negócio central do projeto: registrar uma movimentação de estoque validando saldo, e o novo papel ROLE_ESTOQUISTA que autoriza quem pode fazer isso.
Conceito-Chave: “Saída maior que o saldo disponível” não é um erro técnico (like ResourceNotFoundException) — é uma regra de negócio violada. Segue o mesmo padrão de exceção customizada + handler central já usado em _01.
exception/EstoqueInsuficienteException.javapackage br.com.aula.gestaodeestoques.exception;
public class EstoqueInsuficienteException extends RuntimeException {
public EstoqueInsuficienteException(String message) {
super(message);
}
}
exception/GlobalExceptionHandler.java (handler novo)@ExceptionHandler(EstoqueInsuficienteException.class)
public ResponseEntity<ErrorResponseDTO> handleEstoqueInsuficiente(EstoqueInsuficienteException ex, HttpServletRequest request) {
ErrorResponseDTO error = new ErrorResponseDTO(
Instant.now(), HttpStatus.BAD_REQUEST.value(), "Estoque Insuficiente",
ex.getMessage(), request.getRequestURI());
return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST);
}
⚠️ Achado real durante a implementação: ao testar
@PreAuthorizenum slice@WebMvcTest, descobrimos queGlobalExceptionHandlernão tinha handler paraAccessDeniedException(lançada quando o@PreAuthorizenega acesso). Em produção isso não é visível porque oExceptionTranslationFilterda cadeia de segurança intercepta antes e converte para 403 automaticamente — mas se essa exceção escapar do filtro por qualquer motivo, caía no handler genérico (Exception.class) e virava um 500 opaco em vez de um 403 claro. Adicionamos um handler dedicado como rede de segurança (ver Módulo 03 para o teste que revelou isso).
dto/MovimentacaoEstoqueFormDTO.javapackage br.com.aula.gestaodeestoques.dto;
import br.com.aula.gestaodeestoques.model.TipoMovimentacao;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
public record MovimentacaoEstoqueFormDTO(
@NotNull(message = "O produto é obrigatório.")
Integer produtoId,
@NotNull(message = "O tipo de movimentação é obrigatório.")
TipoMovimentacao tipo,
@NotNull(message = "A quantidade é obrigatória.")
@Positive(message = "A quantidade deve ser maior que zero.")
Integer quantidade
) {}
dto/MovimentacaoEstoqueDTO.javapackage br.com.aula.gestaodeestoques.dto;
import br.com.aula.gestaodeestoques.model.TipoMovimentacao;
import java.time.LocalDateTime;
// DTO para exibir uma movimentação, enriquecido com os nomes de produto e usuário.
public record MovimentacaoEstoqueDTO(
Long id,
Integer produtoId,
String nomeProduto,
TipoMovimentacao tipo,
int quantidade,
LocalDateTime dataHora,
String loginUsuario
) {}
mapper/MovimentacaoEstoqueMapper.java@Component
public class MovimentacaoEstoqueMapper {
public MovimentacaoEstoqueDTO toDTO(MovimentacaoEstoque movimentacao, Produto produto, Usuario usuario) {
return new MovimentacaoEstoqueDTO(
movimentacao.id(),
movimentacao.produtoId(),
produto != null ? produto.nome() : "N/A",
movimentacao.tipo(),
movimentacao.quantidade(),
movimentacao.dataHora(),
usuario != null ? usuario.login() : "N/A"
);
}
}
Conceito-Chave: esta é a regra de negócio central do projeto. Uma SAIDA só é aceita se quantidade solicitada <= quantidade em estoque. As duas escritas (atualizar Produto.quantidade + inserir MovimentacaoEstoque) acontecem na mesma transação — ver o diagrama de sequência do Módulo 00.
service/impl/MovimentacaoEstoqueServiceImpl.java@Service
public class MovimentacaoEstoqueServiceImpl implements MovimentacaoEstoqueService {
private final MovimentacaoEstoqueRepository movimentacaoEstoqueRepository;
private final ProdutoRepository produtoRepository;
private final UsuarioRepository usuarioRepository;
private final MovimentacaoEstoqueMapper mapper;
// ... construtor com injeção via construtor, mesmo padrão de ProdutoServiceImpl ...
@Override
@Transactional
public MovimentacaoEstoqueDTO registrar(MovimentacaoEstoqueFormDTO formDTO) {
Produto produto = produtoRepository.findById(formDTO.produtoId())
.orElseThrow(() -> new ResourceNotFoundException("Produto não encontrado com o ID: " + formDTO.produtoId()));
Usuario usuarioLogado = usuarioAutenticado();
int novaQuantidade;
if (formDTO.tipo() == TipoMovimentacao.SAIDA) {
if (formDTO.quantidade() > produto.quantidade()) {
throw new EstoqueInsuficienteException(
"Estoque insuficiente para o produto '" + produto.nome() + "'. Disponível: "
+ produto.quantidade() + ", solicitado: " + formDTO.quantidade() + ".");
}
novaQuantidade = produto.quantidade() - formDTO.quantidade();
} else {
novaQuantidade = produto.quantidade() + formDTO.quantidade();
}
// Atualiza a quantidade do produto e registra a movimentação na mesma transação:
// se uma das duas falhar, a outra é revertida (nenhuma fica "órfã").
Produto produtoAtualizado = new Produto(produto.id(), produto.nome(), novaQuantidade, produto.preco(),
produto.estoqueMinimo(), produto.categoriaId(), produto.fornecedorId());
produtoRepository.save(produtoAtualizado);
MovimentacaoEstoque movimentacao = new MovimentacaoEstoque(null, produto.id(), formDTO.tipo(),
formDTO.quantidade(), LocalDateTime.now(), usuarioLogado.id());
MovimentacaoEstoque salva = movimentacaoEstoqueRepository.save(movimentacao);
return mapper.toDTO(salva, produtoAtualizado, usuarioLogado);
}
private Usuario usuarioAutenticado() {
String login = SecurityContextHolder.getContext().getAuthentication().getName();
return usuarioRepository.findByLogin(login)
.orElseThrow(() -> new ResourceNotFoundException("Usuário autenticado não encontrado: " + login));
}
// findAll()/findByProdutoId() seguem o mesmo padrão de busca em lote (evitar N+1)
// já usado em ProdutoServiceImpl.findAll() — ver código-fonte completo no projeto.
}
💡 De onde vem
usuarioLogado?SecurityContextHolder.getContext().getAuthentication().getName()devolve ologindo usuário autenticado no token JWT da requisição atual — o mesmo mecanismo que oJwtAuthenticationFilterde_01já popula emSecurityContextHoldera cada requisição. Não é um parâmetro que a SPA envia; o backend descobre “quem está fazendo isso” a partir do próprio token, o que impede um usuário de registrar uma movimentação “em nome de outro”.
ROLE_ESTOQUISTAAção: papéis são seed data, não schema — ROLE_ESTOQUISTA é criado pelo DataSeeder, exatamente como ROLE_ADMIN/ROLE_USER já eram em _01.
config/DataSeeder.java (trechos novos)// 1. Papéis
Papel estoquistaPapel = papelRepository.save(new Papel(null, "ROLE_ESTOQUISTA"));
// 2. Usuário de exemplo
Usuario estoquista = new Usuario(null, "estoquista", passwordEncoder.encode("estoquista123"), true);
Usuario savedEstoquista = usuarioRepository.save(estoquista);
// 3. Associação
usuarioRepository.adicionarPapel(savedEstoquista.id(), estoquistaPapel.id());
O seeder também popula Produto.estoqueMinimo para cada produto de exemplo e insere algumas MovimentacaoEstoque de histórico inicial — incluindo, de propósito, um produto com quantidade abaixo do estoqueMinimo, para o alerta (RF11, Módulo 03) já aparecer populado na primeira execução.
A regra de negócio está completa: validação de saldo, transação atômica, e o papel ROLE_ESTOQUISTA pronto para autorizar quem pode movimentar estoque. No próximo módulo, expomos tudo isso como API REST — os controllers, a autorização por papel nos endpoints, e os endpoints de busca (RF09) e alerta (RF11).