💎 Módulo 04: Relatórios e Frontend

Ú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.


### Aula 4.1: RelatorioService — Agregação em Memória

Conceito-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.

Código: dto/RelatorioMovimentacoesDTO.java

public record RelatorioMovimentacoesDTO(
    int totalEntradas,
    int totalSaidas,
    int saldoLiquido,
    long quantidadeMovimentacoes
) {}

Código: 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 BETWEEN na 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 @Query só para o intervalo de datas — reaproveita findAllOrderByDataHoraDesc() que já existe. Num sistema com histórico de milhões de linhas, a query parametrizada seria a escolha certa; aqui, simplicidade venceu.

Código: 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());
    }
}

### Aula 4.2: Frontend — Tela de Movimentações

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).

Código: 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'),

Código: 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.


### Aula 4.3: Busca, Alerta e Relatórios na Interface

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ão router() precisou ser estendido para separar path de queryString antes de casar contra as rotas, mesclando os dois tipos de parâmetro no mesmo objeto params — a view não precisa saber se um parâmetro veio do caminho ou da query string.


Conclusão do gestaodeestoques_02

Os 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.