Último módulo: RF12 (relatórios gerenciais) no backend, e as telas novas da SPA que dão vida a tudo que foi construído nos módulos anteriores — movimentações, busca, alerta de estoque mínimo e relatórios.
RelatorioService — Agregação em MemóriaConceito-Chave: RF12 não introduz nenhuma tabela nova (ver Módulo 00) — é uma agregação sobre MovimentacaoEstoque que já existe. Mesmo padrão N+1-consciente que ProdutoServiceImpl já usava: busca tudo relevante primeiro, agrega em memória com streams.
dto/RelatorioMovimentacoesDTO.javapublic record RelatorioMovimentacoesDTO(
int totalEntradas,
int totalSaidas,
int saldoLiquido,
long quantidadeMovimentacoes
) {}
service/impl/RelatorioServiceImpl.java@Service
public class RelatorioServiceImpl implements RelatorioService {
private final MovimentacaoEstoqueRepository movimentacaoEstoqueRepository;
private final ProdutoService produtoService;
// ... construtor ...
@Override
@Transactional(readOnly = true)
public RelatorioMovimentacoesDTO gerarRelatorioMovimentacoes(LocalDate inicio, LocalDate fim) {
LocalDateTime inicioDateTime = inicio.atStartOfDay();
LocalDateTime fimDateTime = fim.atTime(LocalTime.MAX);
List<MovimentacaoEstoque> movimentacoesNoPeriodo = movimentacaoEstoqueRepository.findAllOrderByDataHoraDesc().stream()
.filter(m -> !m.dataHora().isBefore(inicioDateTime) && !m.dataHora().isAfter(fimDateTime))
.toList();
int totalEntradas = movimentacoesNoPeriodo.stream()
.filter(m -> m.tipo() == TipoMovimentacao.ENTRADA)
.mapToInt(MovimentacaoEstoque::quantidade).sum();
int totalSaidas = movimentacoesNoPeriodo.stream()
.filter(m -> m.tipo() == TipoMovimentacao.SAIDA)
.mapToInt(MovimentacaoEstoque::quantidade).sum();
return new RelatorioMovimentacoesDTO(totalEntradas, totalSaidas, totalEntradas - totalSaidas, movimentacoesNoPeriodo.size());
}
@Override
@Transactional(readOnly = true)
public List<ProdutoDTO> gerarRelatorioEstoqueBaixo() {
return produtoService.findComEstoqueBaixo(); // RF11 reaproveitado como RF12
}
}
💡 Por que filtrar em memória em vez de
WHERE data_hora BETWEENna query? Para o volume de dados de um projeto didático, a diferença de performance é irrelevante, e filtrar em memória evita criar uma segunda variante de@Querysó para o intervalo de datas — reaproveitafindAllOrderByDataHoraDesc()que já existe. Num sistema com histórico de milhões de linhas, a query parametrizada seria a escolha certa; aqui, simplicidade venceu.
controller/RelatorioController.java@RestController
@RequestMapping("/api/relatorios")
public class RelatorioController {
private final RelatorioService service;
// ... construtor ...
@GetMapping("/movimentacoes")
public ResponseEntity<RelatorioMovimentacoesDTO> relatorioMovimentacoes(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate inicio,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fim) {
return ResponseEntity.ok(service.gerarRelatorioMovimentacoes(inicio, fim));
}
@GetMapping("/estoque-baixo")
public ResponseEntity<List<ProdutoDTO>> relatorioEstoqueBaixo() {
return ResponseEntity.ok(service.gerarRelatorioEstoqueBaixo());
}
}
Ação: nova rota #/movimentacoes no router.js de _01 — formulário de entrada/saída de um lado, histórico do outro. Segue o mesmo padrão de renderCategorias/renderFornecedores (lista + formulário lado a lado).
static/js/api.js (funções novas)buscarProdutos: (nome) => api.fetch(`/produtos/busca?nome=${encodeURIComponent(nome)}`), getEstoqueBaixo: () => api.fetch('/produtos/estoque-baixo'), getMovimentacoes: () => api.fetch('/movimentacoes'), registrarMovimentacao: (data) => api.fetch('/movimentacoes', 'POST', data), getRelatorioMovimentacoes: (inicio, fim) => api.fetch(`/relatorios/movimentacoes?inicio=${inicio}&fim=${fim}`),
getRelatorioEstoqueBaixo: () => api.fetch('/relatorios/estoque-baixo'),
static/js/router.js (view nova, resumida)const renderMovimentacoes = async () => {
const [movimentacoes, produtos] = await Promise.all([api.getMovimentacoes(), api.getProdutos()]);
// ... monta <select> de produtos + <select> ENTRADA/SAIDA + tabela de histórico ...
};
O <form id="movimentacao-form"> é tratado no listener de submit delegado (o mesmo container #app-content, já usado por todos os outros formulários da SPA desde _01):
if (form.id === 'movimentacao-form') {
const data = {
produtoId: parseInt(form.produtoId.value, 10),
tipo: form.tipo.value,
quantidade: parseInt(form.quantidade.value, 10)
};
await api.registrarMovimentacao(data);
window.location.hash = '#/movimentacoes';
}
Se o backend rejeitar (403 sem papel ESTOQUISTA/ADMIN, ou 400 por estoque insuficiente), a mensagem de erro do ErrorResponseDTO aparece no alert() do catch já existente no listener — nenhum tratamento novo de erro precisou ser escrito no frontend.
Ação: a tela de Produtos ganha um campo de busca (RF09) que navega via ?busca= na própria hash-route; a listagem destaca em amarelo (table-warning) qualquer linha com quantidade <= estoqueMinimo (RF11), com um ícone de alerta.
const estoqueBaixo = p.quantidade <= p.estoqueMinimo;
// <tr class="${estoqueBaixo ? 'table-warning' : ''}">
A nova tela #/relatorios combina os dois relatórios de RF12: um formulário de período (data início/fim) que busca getRelatorioMovimentacoes e mostra totais em cards, e uma tabela fixa de “produtos com estoque baixo” (getEstoqueBaixo) — reaproveitando o mesmo endpoint que já alimenta o destaque visual da tela de Produtos.
💡 Por que o roteador da SPA precisou aprender a ler query string? Até
_01, as rotas da hash (#/produtos/editar/5) só tinham parâmetros de caminho (:id). A busca (?busca=notebook) é um parâmetro de query, entãorouter()precisou ser estendido para separarpathdequeryStringantes de casar contra as rotas, mesclando os dois tipos de parâmetro no mesmo objetoparams— a view não precisa saber se um parâmetro veio do caminho ou da query string.
gestaodeestoques_02Os 5 artefatos de design (Módulo 00) viraram código completo: nova modelagem (Módulo 01), regra de negócio com transação e papel novo (Módulo 02), API REST com autorização testada (Módulo 03), e relatórios + frontend (este módulo). RF05-RF12/RNF05 — todo o gap identificado no levantamento de requisitos original — está implementado, testado (MovimentacaoEstoqueServiceImplTest, MovimentacaoEstoqueControllerTest) e integrado à SPA existente.