API backend desenvolvida em Java com Spring Boot para processamento de arquivos CSV/XLSX e geração de relatórios analíticos de vendas.
Este projeto faz parte do Projeto Hanami, uma iniciativa de impacto social voltada ao uso de tecnologia para análise de dados.
- Prazo total: 40 dias
- Metodologia: Desenvolvimento incremental por sprints 1 e 2
- Status atual: Finalizado
Desenvolver uma API robusta capaz de:
- Receber arquivos CSV/XLSX
- Usar um arquivo CSV vendas_ficticias_10000_linhas
- Processar dados de vendas
- Armazenar informações em banco de dados
- Gerar relatórios analíticos
| Foco Principal | Entregas |
|---|---|
| Setup do projeto e arquitetura base | Estrutura inicial do projeto |
| Configuração de ambiente | Perfis dev e prod |
| Início do backend | Parser de dados e endpoint de upload |
| Persistência | Entidades e repositórios iniciais |
| Foco Principal | Entregas |
|---|---|
| Finalização da lógica de análise | Algoritmos completos |
| Relatórios | Geração de relatórios PDF |
| Documentação | README final e instruções de uso |
| Docker | Ambiente produtivo |
- Java 17
- Spring Boot
- MySQL
- Maven
- Docker
src/main/java/com/hanami/iurydev/apiHanami
├── controller # Camada de controle (endpoints REST)
├── dto # Objetos de transferência de dados
├── entity
│ ├── embeddable # Objetos incorporáveis
│ ├── enums # Enumerações do domínio
│ └── Venda # Entidade principal de vendas
├── repository # Interfaces JPA
├── service # Regras de negócio
└── ApiHanamiApplication
spring.datasource.url=jdbc:mysql://localhost:3306/hanamiapidb spring.datasource.username=seu-login-mysql spring.datasource.password=sua-senha-mysql spring.jpa.database-platform=org.hibernate.dialect.MySQLDialect spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver spring.jpa.show-sql=true spring.jpa.hibernate.ddl-auto=update # Aumenta o limite de tamanho do arquivo individual spring.servlet.multipart.max-file-size=50MB # Aumenta o limite total da requisição (arquivo + dados extras) spring.servlet.multipart.max-request-size=50MB spring.jackson.date-format=yyyy-MM-dd spring.jackson.time-zone=America/Sao_Paulo
spring.datasource.url=jdbc:mysql://${MYSQLHOST}:${MYSQLPORT}/${MYSQLDATABASE} spring.datasource.username=${MYSQLUSER} spring.datasource.password=${MYSQLPASSWORD} spring.jpa.hibernate.ddl-auto=update spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver spring.jpa.show-sql=false spring.jpa.properties.hibernate.format_sql=true spring.jackson.date-format=yyyy-MM-dd spring.jackson.time-zone=America/Sao_Paulo
spring.profiles.active=dev- Java 17 (JDK)
- MySQL
- Docker
Passo a passo
- Clone o repositório
git clone https://github.com/IuryDevJava/api-hanami.git
- Entre no diretório
cd api-hanami- Execute a aplicação
./mvnw spring-boot:run
<!-- Leitura de arquivos XLSX --> <dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> <version>5.2.3</version> </dependency> <!-- Leitura de arquivos CSV --> <dependency> <groupId>com.opencsv</groupId> <artifactId>opencsv</artifactId> <version>5.5.2</version> </dependency>
Endpoint responsável por receber arquivos CSV ou XLSX, validar os dados, processar as vendas e persistir no banco de dados
Post /vendas/upload
- Valida a estrutura do arquivo
- Valida regras de negócio campo a campo
- Evita duplicidade por id_transacao
- Persiste apenas registros válidos
- Marca registros inválidos com observações
- URL: /vendas/upload
- Método: POST
- Content-Type: multipart/form-data
| Nome | Tipo | descrição |
|---|---|---|
| file | File | Arquivo CSV ou XLSX com os dados de vendas |
Body
- Type: form-data
- Key: file
- Type: File
- Value: vendas_ficticias_10000_linhas.csv
{
"Status": "sucesso",
"Linhas_processadas": 10000
} {
"Status": "Aviso: Nenhuma nova linha processada",
"Linhas_processadas": 0
} {
"Status": "erro",
"Linhas_processadas": 0
} {
"Status": "Coluna obrigatória ausente: id_transacao",
"Linhas_processadas": 0
}http://localhost:8080/vendas/reports/sales-summary - Retorna total de vendas e a média por transação.
{
"Receita_liquida": 5243176617.89,
"Lucro_bruto": 3099751358.11,
"Total_vendas": 8342927976.00,
"Media_por_transacao": 928849.70,
"Custo_total": 5243176617.89,
"Numero_transacoes": 8982
}http://localhost:8080/vendas/reports/product-analysis - Retorna uma lista de produtos sem ordenação.
[
{
"Nome_produto": "Carregador Wireless",
"Quatidade_vendida": 1006,
"Total_arrecadado": 288235871.00
},
{
"Nome_produto": "iPhone 15",
"Quatidade_vendida": 787,
"Total_arrecadado": 246201201.00
},
{
"Nome_produto": "Apple Watch",
"Quatidade_vendida": 858,
"Total_arrecadado": 249837283.00
}
]http://localhost:8080/vendas/reports/product-analysis?sort_by=quantidade - Retorna os produtos de forma ordenada e por quantidade.
[
{
"Nome_produto": "Cabo USB-C",
"Quatidade_vendida": 1061,
"Total_arrecadado": 339386041.00
},
{
"Nome_produto": "Webcam HD",
"Quatidade_vendida": 1026,
"Total_arrecadado": 319378902.00
},
{
"Nome_produto": "Carregador Wireless",
"Quatidade_vendida": 1006,
"Total_arrecadado": 288235871.00
}
]http://localhost:8080/vendas/reports/product-analysis?sort_by=valor - Retorna os produtos de forma ordenada e por valor.
[
{
"Nome_produto": "Cabo USB-C",
"Quatidade_vendida": 1061,
"Total_arrecadado": 339386041.00
},
{
"Nome_produto": "Webcam HD",
"Quatidade_vendida": 1026,
"Total_arrecadado": 319378902.00
},
{
"Nome_produto": "Chromecast",
"Quatidade_vendida": 934,
"Total_arrecadado": 294529780.00
}
]http://localhost:8080/vendas/reports/financial-metrics - Retorna um JSON com lucro_bruto, receita_liquida e custo_total.
{
"Receita_liquida": 5243176617.89,
"Lucro_bruto": 3099751358.11,
"Custo_total": 5243176617.89
}http://localhost:8080/vendas/reports/regional-performance - Retorna um JSON com cada região como chave e suas métricas como valor.
http://localhost:8080/vendas/reports/customer-profile - Retorna um JSON com as distribuições demográficas.
http://localhost:8080/vendas/reports/download?format=json - Retorna um arquivo report.json e faz o download completo do arquivo com as métricas em formato JSON.
http://localhost:8080/vendas/reports/download?format=pdf - Retorna um arquivo report.pdf e faz o download com as métricas em tabela e um gráfico de barras de vendas por região.
- Formato do id_transacao (ex: TXN12345678)
- Margem de lucro mínima e máxima
- Idade do cliente
- Formato de IDs de cliente, produto e vendedor
- Datas válidas
- Campos obrigatórios não nulos
- Enumerações normalizadas (canal de venda, forma de pagamento, região, status de entrega)
A aplicação utiliza o modelo de persistência onde os dados do Produto são tratados como objetos incorporáveis (@Embeddable), resultando em uma tabela única de vendas para otimização de performance analítica.
Criar e usar o banco (não esqueça que o nome do banco precisa ser o mesmo no arquivo properties em spring.datasource.url=jdbc:mysql://localhost:3306/hanamiapidb)
CREATE DATABASE hanamiapidb; USE hanamiapidb;
SHOW TABLES;
SELECT COUNT(*) FROM vendas;
SELECT * FROM vendas LIMIT 10;
SELECT id_transacao, observacao_validada FROM vendas WHERE processado_sucesso = false;
SELECT processado_sucesso, COUNT(*) FROM vendas GROUP BY processado_sucesso;
DROP TABLE hanamiapidb.vendas;
SELECT SUM(valor_final) AS total_vendas, SUM(valor_final * (margem_lucro / 100)) AS lucro_bruto, SUM(valor_final) - SUM(valor_final * (margem_lucro / 100)) AS receita_liquida, SUM(valor_final - (valor_final * (margem_lucro / 100))) AS custo_total, AVG(valor_final) AS media_por_transacao, COUNT(*) AS numero_transacoes FROM vendas WHERE processado_sucesso = 1;
SELECT nome_produto, COUNT(*) AS quantidade_vendida, SUM(valor_final) AS total_arrecadado FROM vendas WHERE processado_sucesso = 1 GROUP BY nome_produto ORDER BY total_arrecadado DESC;
SELECT SUM(valor_final * (margem_lucro / 100)) AS lucro_bruto, SUM(valor_final) - SUM(valor_final * (margem_lucro / 100)) AS receita_liquida, SUM(valor_final - (valor_final * (margem_lucro / 100))) AS custo_total FROM vendas WHERE processado_sucesso = 1;
A API utiliza logging estruturado para registrar eventos importantes durante o processamento de arquivos, facilitando:
- Monitoramento da aplicação
- Debug de erros
- Auditoria de processamento
- Análise de falhas em produção
@Slf4j @RestController @RequestMapping("/vendas") public class VendaController { }
200 OK. Arquivo 'vendas_ficticias_10000_linhas.csv' foi processado com sucesso. Total: 10000 linhas
- Upload válido
- Arquivo lido corretamente
- Processamento finalizado sem erros
Erro 400. Ao tentar fazer o upload sem arquivo foi retornado um erro
- Parâmetro file não enviado
- Arquivo vazio
Erro 422. Arquivo enviado não contém uma ou mais colunas obrigatórias Coluna obrigatória ausente: id_transacao
- CSV/XLSX não possui colunas obrigatórias
- Estrutura incompatível com o parser
Erro crítico durante o processamento de upload
- Exceções inesperadas
- Falhas de I/O, parsing ou banco de dados
<dependency> <groupId>org.springdoc</groupId> <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId> <version>2.3.0</version> </dependency>
springdoc.swagger-ui.path=/docsTodos os endpoints terão um sumário e uma descrição, então adicione esse exemplo e mude de acordo com o endpoint e sua responsabilidade
@Operation(
summary = "Upload",
description = "Faz o upload de arquivo CSV/XLSX"
)
No endpoit de analisar o produto que tem @GetMapping("/reports/product-analysis"), antes do @RequestParam adicione:
@Parameter(description = "Critério de ordenação", example = "nome", schema = @Schema(allowableValues = {"nome", "preco", "quantidade"}))
No endpoit desenvolvido pra fazer o downloiad do relatório, que tem @GetMapping("/reports/download"), antes do @RequestParam adicione:
@Parameter(description = "Formato do arquivo", required = true, schema = @Schema(allowableValues = {"json", "pdf"}))
Os filtros abaixo utilizam Query Methods do Spring Data JPA, onde as consultas são derivadas automaticamente a partir do nome do método.
List<Venda> findByProcessadoSucessoTrueAndCliente_Estado(String estado);
No endpoint /reports/regional-performance, adicione um filtro opcional por sigla do estado (ex: SP):
@Operation( summary = "Clientes e Região", description = "Retorna métricas agrupadas por região, com filtro opcional por estado." ) @GetMapping("/reports/regional-performance") public ResponseEntity<Map<String, MetricasRegiaoDTO>> getRegionalPerformance( @RequestParam(required = false) String estado) { List<Venda> vendas; if(estado != null && !estado.trim().isEmpty()) { vendas = vendaRepository.findByProcessadoSucessoTrueAndCliente_Estado(estado.toUpperCase()); } else { vendas = vendaRepository.findByProcessadoSucessoTrue(); } List<MetricasRegiaoDTO> lista = vendaCalcularService.calcularMetricasPorRegiao(vendas); // FILTRO ADICIONAL: Se o usuário pediu um estado, garantimos que o mapa // contenha apenas a região daquele estado. Map<String, MetricasRegiaoDTO> mapa = lista.stream() .collect(Collectors.toMap( MetricasRegiaoDTO::getRegiao, dto -> dto )); // Se houver filtro de estado, podemos filtrar o mapa final também if (estado != null && !estado.trim().isEmpty() && !vendas.isEmpty()) { // Pega a região do primeiro item da lista filtrada (já que todos são do mesmo estado) String regiaoFiltrada = vendas.get(0).getLogistica().getRegiao().toString(); return ResponseEntity.ok(Map.of(regiaoFiltrada, mapa.get(regiaoFiltrada))); } return ResponseEntity.ok(mapa); }
List<Venda> findByDataVendaBetween(LocalDate startDate, LocalDate endDate);
@Operation( summary = "Relatório de Vendas", description = "Retorna o resumo financeiro consolidado, com filtro opcional por período." ) @GetMapping("/reports/sales-summary") public ResponseEntity<RelatorioFinanceiroDTO> getSalesSumary( @RequestParam(required = false) String startDate, @RequestParam(required = false) String endDate) { List<Venda> vendas; if (startDate != null && endDate != null) { // Conversão obrigatória: String -> LocalDate LocalDate dataInicio = LocalDate.parse(startDate); LocalDate dataFim = LocalDate.parse(endDate); vendas = vendaRepository.findByDataVendaBetween(dataInicio, dataFim); } else { vendas = vendaRepository.findAll(); } return ResponseEntity.ok(vendaCalcularService.calculaFinanceiro(vendas)); }
Limpa o projeto
mvn clean
Limpa o projeto e verifica se o código compila e se não tem erros de sintaxe
mvn clean compile
**Limpa, compila e empacota a aplicação dentro de um arquivo .jar
mvn clean package -DskipTests
Faça um teste e observe os logs para ver se está tudo certo
mvn test FROM maven:3.9.6-eclipse-temurin-17 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn clean package -DskipTests
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
services: db: image: mysql:8.0 container_name: hanami-db restart: always environment: MYSQL_DATABASE: hanamiapidb MYSQL_ROOT_PASSWORD: root ports: - "3307:3306" app: build: . container_name: hanami-app ports: - "8080:8080" depends_on: - db environment: - SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/hanamiapidb?createDatabaseIfNotExist=true - SPRING_DATASOURCE_USERNAME=root - SPRING_DATASOURCE_PASSWORD=root - SPRING_JPA_HIBERNATE_DDL_AUTO=update
Observação: Se quiser acessar o banco via Workbench/DBeaver enquanto o Docker roda, use a porta 3307
- Nota: Para demonstração, as credenciais do banco estão no arquivo, mas em produção devem ser usadas em variáveis de ambiente.
docker build -t projeto-apihanami .docker compose up -d
http://localhost:8080/docs
docker-compose down -v
[X] Upload: Os arquivos com a extensão .CSV e .XLSX estão sendo aceitos corretamente? (Sim)
[X] Código: O projeto compila sem erros? (Sim)
[X] Testes: Os endpoints no Postman e Swagger batem com os resultados do SQL? (Sim)
[X] Documentação: O README reflete a realidade do código? (Sim)
[X] Swagger/OpenAPI: Os endpoints estão documentados? (Sim)
[X] Docker: A aplicação roda corretamente via Docker? (Sim)
[X] Logs: Mensagens de sucesso e de erro foram registradas? (Sim)
[X] Tratamento de Exceções: Se eu enviar um CSV corrompido ou com colunas erradas, a API retorna um erro claro (ex: 400 Bad Request) (Sim)
[X] Validação na lógica de negócio: Filtros de data e ordenação funcionando via Query Param