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 cleanLimpa 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 -DskipTestsFaç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=updateObservaçã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