diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ffdd5d9 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +DB_HOST=your_host_here +DB_PORT=3306 +DB_USER=your_user_here +DB_PASSWORD=your_password_here +DB_NAME=your_database_here diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc86d77 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Credenciais — NUNCA versionar +.env + +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +env/ + +# Jupyter +.ipynb_checkpoints/ + +# Scratch / saídas temporárias +output/ +scratch/ + +# PDF da solução (entregue via formulário, não versionado) +*.pdf + +# OS / Editor +.DS_Store +Thumbs.db +.vscode/ +.idea/ diff --git a/SOLUCAO.md b/SOLUCAO.md new file mode 100644 index 0000000..4151b9c --- /dev/null +++ b/SOLUCAO.md @@ -0,0 +1,301 @@ +# Desafio Técnico Looqbox — Analista de Dados e BI + +**Candidato:** Vinicius Henrique Albino Andrade + +--- + +## Como executar + +- **Stack:** Python 3.14, MySQL, SQLAlchemy + PyMySQL, pandas, matplotlib +- **Credenciais:** carregadas de um arquivo `.env` (template em `.env.example`); nunca versionadas (`.gitignore`) +- **Instalação:** `pip install -r requirements.txt` +- **Conexão isolada:** `db.py` centraliza a criação do engine via `URL.create` (escapa caracteres especiais do nome do schema/usuário) com `pool_pre_ping=True` como safeguard + +``` +data-challenge/ +├── db.py # conexão isolada (engine único, reutilizável) +├── .env.example # template das variáveis +├── requirements.txt +├── sql_test/ +│ ├── query1.sql +│ ├── query2.sql +│ └── query3.sql +├── case1_retrieve_data.py +├── case2_visualization.py +└── case3_imdb.py +``` + +--- + +## SQL Test + +### Query 1 — Os 10 produtos mais caros + +```sql +SELECT dp.PRODUCT_COD, dp.PRODUCT_NAME, dp.PRODUCT_VAL +FROM `looqbox-challenge`.data_product dp +ORDER BY dp.PRODUCT_VAL DESC, dp.PRODUCT_COD ASC +LIMIT 10; +``` + +**Resultado:** + +| PRODUCT_COD | PRODUCT_NAME | PRODUCT_VAL | +| ----------- | --------------------------------------------------------------- | ----------- | +| 301409 | Whisky Escoces THE MACALLAN Ruby Garrafa 700ml com Caixa | 741.99 | +| 176185 | Whisky Escoces JOHNNIE WALKER Blue Label Garrafa 750ml | 735.90 | +| 315481 | Cafeteira Expresso 3 CORACOES Tres Modo Vermelho | 499.00 | +| 100280 | Vinho Portugues Tinto Vintage QUINTA DO CRASTO Garrafa 750ml | 445.90 | +| 320046 | Escova Dental Eletrica ORAL B D34 Professional Care 5000 110v | 399.90 | +| 190817 | Champagne Rose VEUVE CLICQUOT PONSARDIM Garrafa 750ml | 366.90 | +| 153795 | Champagne Frances Brut Imperial MOET Rose Garrafa 750ml | 359.90 | +| 311397 | Conjunto de Panelas Allegra em Inox TRAMONTINA 5 Pecas | 359.00 | +| 147706 | Whisky Escoces CHIVAS REGAL 18 Anos Garrafa 750ml | 329.90 | +| 44311 | Champagne Frances Demi Sec Nectar Imperial MOET & CHANDON 750ml | 315.90 | + +**Decisão de design — desempate determinístico:** +O 10º e o 11º produtos empatam em preço (R$ 315,90 — há duas Champagnes MOET). Mantive `LIMIT 10` conforme o enunciado e adicionei `PRODUCT_COD` como critério de desempate no `ORDER BY`. Sem esse critério, o MySQL poderia retornar produtos diferentes entre execuções; no caso de empate o desempate garante **resultado reproduzível**. + +Caso o requisito fosse incluir todos os empatados no 10º valor, usaria `DENSE_RANK() <= 10`. + +--- + +### Query 2 — Seções dos departamentos BEBIDAS e PADARIA + +```sql +SELECT DISTINCT dp.DEP_NAME, dp.SECTION_NAME +FROM `looqbox-challenge`.data_product dp +WHERE dp.DEP_NAME IN ('BEBIDAS', 'PADARIA') +ORDER BY dp.DEP_NAME; +``` + +**Resultado:** + +| DEP_NAME | SECTION_NAME | +| -------- | ------------------ | +| BEBIDAS | BEBIDAS | +| BEBIDAS | CERVEJAS | +| BEBIDAS | REFRESCOS | +| BEBIDAS | VINHOS | +| PADARIA | DOCES-E-SOBREMESAS | +| PADARIA | GESTANTE | +| PADARIA | PADARIA | +| PADARIA | QUEIJOS-E-FRIOS | + +**Decisões:** + +- `DISTINCT` elimina a duplicação: cada seção se repete uma vez por produto, então sem o `DISTINCT` a mesma seção apareceria várias vezes. +- **Verificação prévia:** antes de finalizar, listei todos os departamentos para garantir que não havia variações de nome que deveriam ser incluídas. + +--- + +### Query 3 — Venda total por Business Area no 1º trimestre de 2019 + +```sql +SELECT dsc.BUSINESS_NAME, + SUM(dps.SALES_VALUE) AS TOTAL_VENDA +FROM `looqbox-challenge`.data_product_sales dps +JOIN `looqbox-challenge`.data_store_cad dsc + ON CAST(dps.STORE_CODE AS UNSIGNED) = dsc.STORE_CODE +WHERE dps.`DATE` BETWEEN '2019-01-01' AND '2019-03-31' +GROUP BY dsc.BUSINESS_NAME +ORDER BY TOTAL_VENDA DESC; +``` + +**Resultado:** + +| BUSINESS_NAME | TOTAL_VENDA | +| ------------- | ------------- | +| Farma | 81.776.691,73 | +| Varejo | 81.032.347,65 | +| Atacado | 80.384.884,60 | +| Proximidade | 80.171.122,80 | +| Posto | 32.072.326,40 | + +**Decisões técnicas:** + +- **Mismatch de tipo no JOIN:** `STORE_CODE` é `varchar` em `data_product_sales` e `int` em `data_store_cad`. Apliquei `CAST(dps.STORE_CODE AS UNSIGNED)` para alinhar os tipos no JOIN. +- **Filtro de período:** `DATE` é tipo `DATE` (sem hora), então `BETWEEN '2019-01-01' AND '2019-03-31'` inclui o dia 31/03 integralmente. +- **INNER JOIN:** adequado aqui, uma venda cujo `STORE_CODE` não exista no cadastro não tem Business Area atribuível, logo deve ficar de fora da agregação por área. +- Observação de negócio: "Posto" fatura bem menos que as demais áreas (~32M vs ~80M), coerente com o menor mix de produtos de um posto. + +--- + +## Case 1 — Função dinâmica `retrieve_data` + +```python +import pandas as pd +from sqlalchemy import text +from db import get_engine + +engine = get_engine() + +def retrieve_data(product_code=None, store_code=None, date=None): + store_code_param = None if store_code is None else str(store_code) + + filtros = [ + (product_code, "PRODUCT_CODE = :pcode", {"pcode": product_code}), + (store_code, "STORE_CODE = :scode", {"scode": store_code_param}), + ] + + ativos = [f for f in filtros if f[0] is not None] + + if date is not None: + if not isinstance(date, (list, tuple)) or len(date) != 2: + raise ValueError( + "date deve ser uma lista com exatamente 2 elementos: [inicio, fim]." + ) + ativos.append( + (date, "DATE BETWEEN :ini AND :fim", {"ini": date[0], "fim": date[1]}) + ) + + if not ativos: + raise ValueError( + "Informe ao menos um filtro: product_code, store_code ou date." + ) + + where = "WHERE " + " AND ".join(trecho for _, trecho, _ in ativos) + + params = {} + for _, _, p in ativos: + params.update(p) + + query = f"SELECT * FROM data_product_sales {where}" + return pd.read_sql(text(query), engine, params=params) +``` + +**Exemplos de uso e resultados (linhas retornadas):** + +| Chamada | Resultado | +| ----------------------------------------------------------------------------------- | ----------------------------------------- | +| `retrieve_data(product_code=67108)` | 35.762 linhas | +| `retrieve_data(store_code=1)` | 136.729 linhas | +| `retrieve_data(date=['2019-01-01','2019-01-31'])` | 39.401 linhas | +| `retrieve_data(product_code=67108, store_code=1, date=['2019-01-01','2019-03-31'])` | combinação dos filtros | +| `retrieve_data()` | levanta `ValueError` (ver decisão abaixo) | +| `retrieve_data(product_code=301409)` | 0 linhas (ver insight) | + +**Decisões de design:** + +- **Filtros opcionais declarativos:** construo a cláusula `WHERE` iterando sobre uma lista de filtros, em vez de uma cadeia de `if`. Evita repetição e facilita adicionar novos filtros. +- **`is not None`:** `if product_code:` trataria `0` como ausente; `is not None` checa literalmente se o filtro foi informado, independente do valor. +- **Query parametrizada:** uso placeholders (`:pcode`, etc.) e passo os valores separadamente. Como o enunciado diz que "outros times vão usar a função", isso protege contra SQL Injection. +- **Exige ao menos um filtro:** sem nenhum filtro, levanto `ValueError`. `data_product_sales` é uma tabela transacional grande; retornar tudo sem filtro seria arriscado (memória/performance). Forçar uso intencional é o comportamento mais seguro para quem chama. +- **Tratamento do varchar:** `STORE_CODE` é `varchar` na tabela; converto o parâmetro para `str` para garantir o match. +- **Conexão reutilizável:** o engine é criado uma vez (nível de módulo) e reutilizado, em vez de recriado a cada chamada. + +**Insight de negócio:** o produto mais caro da empresa (THE MACALLAN, código 301409) tem **zero vendas**, enquanto produtos de baixo valor (ex.: 67108) vendem mais de 35 mil vezes — coerente com o comportamento de um item de luxo. + +--- + +## Case 2 — Ticket Médio por Loja/Categoria + +As duas queries fornecidas foram usadas **exatamente como recebidas** (sem modificação). Como a Query 2 traz o ano inteiro e o pedido é o período `['2019-10-01','2019-12-31']`, o **filtro do 4º trimestre é feito no pandas** — respeitando a restrição de não alterar a query. + +```python +import pandas as pd +from sqlalchemy import text +from db import get_engine + +engine = get_engine() + +query_lojas = text(""" +SELECT STORE_CODE, STORE_NAME, START_DATE, END_DATE, BUSINESS_NAME, BUSINESS_CODE +FROM data_store_cad +""") + +query_vendas = text(""" +SELECT STORE_CODE, DATE, SALES_VALUE, SALES_QTY +FROM data_store_sales +WHERE DATE BETWEEN '2019-01-01' AND '2019-12-31' +""") + +df_lojas = pd.read_sql(query_lojas, engine) +df_vendas = pd.read_sql(query_vendas, engine) + +# Filtro do 4º trimestre no pandas (a query não pode ser modificada) +df_vendas["DATE"] = pd.to_datetime(df_vendas["DATE"]) +q4 = df_vendas[(df_vendas["DATE"] >= "2019-10-01") & (df_vendas["DATE"] <= "2019-12-31")] + +# Agregação por loja e Ticket Médio = soma(valor) / soma(qtd) +agg = q4.groupby("STORE_CODE").agg( + valor_total=("SALES_VALUE", "sum"), + qtd_total=("SALES_QTY", "sum"), +).reset_index() +agg["TM"] = (agg["valor_total"] / agg["qtd_total"]).round(2) + +final = agg.merge(df_lojas, on="STORE_CODE") +resultado = final[["STORE_NAME", "BUSINESS_NAME", "TM"]] +resultado.columns = ["Loja", "Categoria", "TM"] +resultado = resultado.sort_values("Loja").reset_index(drop=True) +print(resultado) +``` + +**Resultado (confere 100% com a tabela esperada do enunciado):** + +| Loja | Categoria | TM | +| -------------- | ----------- | ----- | +| Bahia | Atacado | 15.39 | +| Bangkok | Posto | 13.67 | +| Belem | Proximidade | 15.37 | +| Berlin | Proximidade | 15.39 | +| Buenos Aires | Atacado | 15.39 | +| Chicago | Varejo | 15.53 | +| Dubai | Atacado | 15.39 | +| Hong Kong | Farma | 26.35 | +| London | Farma | 28.99 | +| Madri | Farma | 29.03 | +| Miami | Posto | 13.67 | +| New York | Proximidade | 15.39 | +| Paris | Proximidade | 15.39 | +| Rio de Janeiro | Farma | 29.59 | +| Roma | Varejo | 15.39 | +| Salvador | Atacado | 15.39 | +| Sao Paulo | Varejo | 15.39 | +| Sidney | Posto | 13.67 | +| Tokio | Varejo | 15.39 | +| Vancouver | Posto | 13.67 | + +**Decisões:** + +- **Restrição respeitada:** as queries foram usadas sem qualquer alteração; o recorte do trimestre acontece na camada de aplicação. +- **Ticket Médio = soma(valor) / soma(qtd)** sobre o período, **não** a média das razões diárias. A média das médias daria peso igual a dias de volume diferente, distorcendo o ticket. O agregado (soma/soma) é o ticket médio correto. +- **Merge por `STORE_CODE`:** ambos são `int` nas duas tabelas, então o merge é direto (sem o problema de tipo do Case 1). + +--- + +## Case 3 — Visualizações (tabela `IMDB_movies`) + +Os três gráficos respondem, juntos, a uma pergunta: **o que se relaciona com o sucesso de um filme?** Em todos, valores ausentes (`NULL`) são removidos antes da análise, e a quantidade de filmes (`n`) é exibida para evitar conclusões a partir de amostras pequenas. + +### Gráfico 1 — Receita média por gênero + +![Receita média por gênero](case3_receita_por_genero.png) + +**Por quê:** gráfico de barras compara categorias discretas (gêneros). A coluna `Genre` é multivalorada ("Action,Adventure,Sci-Fi"); apliquei `split` + `explode` para gerar uma linha por gênero e analisar cada um individualmente. O `n` ao lado de cada barra evita interpretar uma média baseada em poucos filmes como representativa. + +### Gráfico 2 — Receita média por nota (Rating) + +![Receita média por nota](case3_rating_vs_receita.png) + +**Por quê:** `Rating` contém apenas valores inteiros, então agreguei a receita média por nota. Incluí o coeficiente de **Spearman** (correlação de postos, apropriada para variável ordinal inteira) para quantificar a relação entre nota e receita e o `n` por nota para sinalizar onde a média é frágil. + +**Spearman ρ = 0,15** — correlação **fraca**: a nota de um filme tem pouca relação com a receita. Ou seja, filme bem avaliado não necessariamente fatura mais, outros fatores como marketing, franquia ou distribuição podem pesar mais na bilheteria. + +### Gráfico 3 — Público (Rating) vs Crítica (Metascore) + +![Público vs Crítica](case3_publico_vs_critica.png) + +**Por quê:** boxplot do `Metascore` (crítica) para cada nota do público mostra, pela forma, se as avaliações sobem juntas. O **Spearman** quantifica a concordância, e o `n` por caixa evita interpretar uma caixa de um único filme como tendência. + +**Spearman ρ = 0,62**, correlação **moderada-positiva**: público e crítica concordam razoavelmente (notas altas do público tendem a ter Metascore alto), mas a concordância não é perfeita, há filmes amados pelo público e mornos para a crítica, e vice-versa. + +**Nota analítica (receita):** usei **média** de receita. Estou ciente de que receita é assimétrica (poucos blockbusters inflam a média); em uma análise de produção, consideraria a **mediana** para representar a receita típica. + +--- + +## Uso de Inteligência Artificial + +Utilizei assistência de IA (Claude) como **apoio de mentoria**: para esclarecer conceitos, revisar minha lógica e apontar bugs. + +As queries SQL, a função `retrieve_data`, as transformações em pandas e as visualizações foram **escritas e compreendidas por mim**. Testei cada etapa diretamente contra o banco e validei os resultados. Onde já tinha conhecimento (SQL, pandas) apliquei diretamente; onde tive dúvida conceitual, pesquisei e confirmei o entendimento antes de implementar. diff --git a/case1_retrieve_data.py b/case1_retrieve_data.py new file mode 100644 index 0000000..0d99a3f --- /dev/null +++ b/case1_retrieve_data.py @@ -0,0 +1,45 @@ +import pandas as pd +from sqlalchemy import text +from db import get_engine + +engine = get_engine() # criado UMA vez, reusado em toda chamada + + +def retrieve_data(product_code=None, store_code=None, date=None): + # STORE_CODE é varchar na tabela; convertemos para str para garantir o match. + store_code_param = None if store_code is None else str(store_code) + + filtros = [ + (product_code, "PRODUCT_CODE = :pcode", {"pcode": product_code}), + (store_code, "STORE_CODE = :scode", {"scode": store_code_param}), + ] + + ativos = [f for f in filtros if f[0] is not None] + + if date is not None: + if not isinstance(date, (list, tuple)) or len(date) != 2: + raise ValueError( + "date deve ser uma lista com exatamente 2 elementos: [inicio, fim]." + ) + ativos.append( + (date, "DATE BETWEEN :ini AND :fim", {"ini": date[0], "fim": date[1]}) + ) + + if not ativos: + raise ValueError( + "Informe ao menos um filtro: product_code, store_code ou date." + ) + + where = "WHERE " + " AND ".join(trecho for _, trecho, _ in ativos) + + params = {} + for _, _, p in ativos: + params.update(p) + + query = f"SELECT * FROM data_product_sales {where}" + return pd.read_sql(text(query), engine, params=params) + +if __name__ == "__main__": + print(retrieve_data(product_code=67108).head()) + print(retrieve_data(store_code=1, date=['2019-10-01','2019-12-31']).head()) + diff --git a/case2_visualization.py b/case2_visualization.py new file mode 100644 index 0000000..d4b5bc0 --- /dev/null +++ b/case2_visualization.py @@ -0,0 +1,59 @@ +import pandas as pd +from sqlalchemy import text +from db import get_engine + +engine = get_engine() + +query_lojas = text(""" +SELECT + STORE_CODE, + STORE_NAME, + START_DATE, + END_DATE, + BUSINESS_NAME, + BUSINESS_CODE +FROM data_store_cad +""") + +query_vendas = text(""" +SELECT + STORE_CODE, + DATE, + SALES_VALUE, + SALES_QTY +FROM data_store_sales +WHERE DATE BETWEEN '2019-01-01' AND '2019-12-31' +""") + +df_lojas = pd.read_sql(query_lojas, engine) +df_vendas = pd.read_sql(query_vendas, engine) + +# 1. Garantir datetime +df_vendas["DATE"] = pd.to_datetime(df_vendas["DATE"]) + +# 2. Filtrar Q4 (restrição: não modificar a query, filtra aqui) +q4 = df_vendas[ + (df_vendas["DATE"] >= "2019-10-01") & + (df_vendas["DATE"] <= "2019-12-31") +] + +# 3. Agregar por loja +agg = q4.groupby("STORE_CODE").agg( + valor_total=("SALES_VALUE", "sum"), + qtd_total=("SALES_QTY", "sum"), +).reset_index() + +# 4. Ticket Médio +agg["TM"] = (agg["valor_total"] / agg["qtd_total"]).round(2) + +# 5. Merge com lojas +final = agg.merge(df_lojas, on="STORE_CODE") + +# 6. Tabela final +resultado = final[["STORE_NAME", "BUSINESS_NAME", "TM"]] +resultado.columns = ["Loja", "Categoria", "TM"] + +# 7. Ordenar por Loja +resultado = resultado.sort_values("Loja").reset_index(drop=True) + +print(resultado) diff --git a/case3_imdb.py b/case3_imdb.py new file mode 100644 index 0000000..f275041 --- /dev/null +++ b/case3_imdb.py @@ -0,0 +1,90 @@ +import pandas as pd +import matplotlib.pyplot as plt +from sqlalchemy import text +from db import get_engine + +engine = get_engine() +df = pd.read_sql(text("SELECT * FROM IMDB_movies"), engine) + +# --- Gráfico 1 — Receita média por gênero --- +df1 = df.dropna(subset=["RevenueMillions"]) + +df1 = df1.assign(Genre=df1["Genre"].str.split(",")).explode("Genre") +df1["Genre"] = df1["Genre"].str.strip() + +receita_genero = ( + df1.groupby("Genre")["RevenueMillions"] + .mean() + .sort_values(ascending=False) + .head(10) +) +contagem_genero = df1.groupby("Genre").size() + +plt.figure(figsize=(10, 6)) +plot_genero = receita_genero.sort_values() +ax = plot_genero.plot(kind="barh") +ax.set_axisbelow(True) +ax.grid(axis="x", linestyle="--", alpha=0.7) +rotulos_genero = [ + f"{media:.1f} (n={contagem_genero[genero]})" + for genero, media in plot_genero.items() +] +ax.bar_label(ax.containers[0], labels=rotulos_genero, padding=3) +plt.xlabel("Receita média (milhões $)") +plt.title("Receita média por gênero (Top 10)") +plt.figtext(0.99, 0.01, "n = quantidade de filmes do gênero", ha="right", fontsize=9, style="italic") +plt.tight_layout() +plt.savefig("case3_receita_por_genero.png", dpi=120) +plt.close() + +# --- Gráfico 2 — Rating × Receita (receita média por nota) --- +df2 = df.dropna(subset=["RevenueMillions", "Rating"]) +receita_por_nota = df2.groupby("Rating")["RevenueMillions"].mean() +contagem_nota = df2.groupby("Rating").size() + +corr_receita = df2["Rating"].corr(df2["RevenueMillions"], method="spearman") + +plt.figure(figsize=(9, 6)) +ax = receita_por_nota.plot(kind="bar") +ax.set_axisbelow(True) +ax.grid(axis="y", linestyle="--", alpha=0.7) +rotulos_nota = [ + f"{media:.1f}\n(n={contagem_nota[nota]})" + for nota, media in receita_por_nota.items() +] +ax.bar_label(ax.containers[0], labels=rotulos_nota, padding=3) +plt.xlabel("Rating (nota)") +plt.ylabel("Receita média (milhões $)") +plt.title( + f"Receita média por nota — filme bem avaliado fatura mais? " + f"(Spearman ρ={corr_receita:.2f})" +) +plt.xticks(rotation=0) +plt.figtext(0.99, 0.01, "n = quantidade de filmes com aquela nota", ha="right", fontsize=9, style="italic") +plt.tight_layout() +plt.savefig("case3_rating_vs_receita.png", dpi=120) +plt.close() + +# --- Gráfico 3 — Público (Rating) × Crítica (Metascore) --- +df3 = df.dropna(subset=["Rating", "Metascore"]) +notas = sorted(df3["Rating"].unique()) +dados_por_nota = [df3.loc[df3["Rating"] == nota, "Metascore"] for nota in notas] + +corr_critica = df3["Rating"].corr(df3["Metascore"], method="spearman") + +plt.figure(figsize=(9, 6)) +ax = plt.gca() +ax.set_axisbelow(True) +ax.grid(axis="y", linestyle="--", alpha=0.7) +rotulos_box = [f"{int(nota)}\nn={len(metascores)}" for nota, metascores in zip(notas, dados_por_nota)] +plt.boxplot(dados_por_nota, tick_labels=rotulos_box) +plt.xlabel("Rating (público)") +plt.ylabel("Metascore (crítica)") +plt.title( + f"Metascore por nota do público — público e crítica concordam? " + f"(Spearman ρ={corr_critica:.2f})" +) +plt.figtext(0.99, 0.01, "n = quantidade de filmes com aquela nota", ha="right", fontsize=9, style="italic") +plt.tight_layout() +plt.savefig("case3_publico_vs_critica.png", dpi=120) +plt.close() diff --git a/case3_publico_vs_critica.png b/case3_publico_vs_critica.png new file mode 100644 index 0000000..268cf55 Binary files /dev/null and b/case3_publico_vs_critica.png differ diff --git a/case3_rating_vs_receita.png b/case3_rating_vs_receita.png new file mode 100644 index 0000000..9d9f8d8 Binary files /dev/null and b/case3_rating_vs_receita.png differ diff --git a/case3_receita_por_genero.png b/case3_receita_por_genero.png new file mode 100644 index 0000000..41367f8 Binary files /dev/null and b/case3_receita_por_genero.png differ diff --git a/db.py b/db.py new file mode 100644 index 0000000..92d445d --- /dev/null +++ b/db.py @@ -0,0 +1,19 @@ +import os +from dotenv import load_dotenv +from sqlalchemy import create_engine +from sqlalchemy.engine import URL + +load_dotenv() + +def get_engine(): + url = URL.create( + drivername="mysql+pymysql", + username=os.getenv("DB_USER"), + password=os.getenv("DB_PASSWORD"), + host=os.getenv("DB_HOST"), + port=int(os.getenv("DB_PORT", 3306)), + database=os.getenv("DB_NAME"), + ) + return create_engine(url, pool_pre_ping=True) + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a79ff7e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +pandas +sqlalchemy +pymysql +python-dotenv +matplotlib diff --git a/sql_test/query1.sql b/sql_test/query1.sql new file mode 100644 index 0000000..b3eaf9c --- /dev/null +++ b/sql_test/query1.sql @@ -0,0 +1,11 @@ +-- Query 1 — Os 10 produtos mais caros da empresa +-- Decisão: desempate determinístico por PRODUCT_COD. +-- O 10º e 11º produtos empatam em preço (R$ 315,90). Mantive LIMIT 10 +-- conforme o enunciado e adicionei PRODUCT_COD no ORDER BY para garantir +-- resultado reproduzível (sem o critério, o MySQL poderia retornar +-- produtos diferentes entre execuções no caso de empate). + +SELECT dp.PRODUCT_COD, dp.PRODUCT_NAME, dp.PRODUCT_VAL +FROM `looqbox-challenge`.data_product dp +ORDER BY dp.PRODUCT_VAL DESC, dp.PRODUCT_COD ASC +LIMIT 10; diff --git a/sql_test/query2.sql b/sql_test/query2.sql new file mode 100644 index 0000000..6a7ada4 --- /dev/null +++ b/sql_test/query2.sql @@ -0,0 +1,10 @@ +-- Query 2 — Seções dos departamentos BEBIDAS e PADARIA +-- DISTINCT elimina a duplicação (cada seção se repete por produto). +-- Verificação prévia: confirmei a lista completa de departamentos para +-- garantir que não havia variações de nome (ex.: "BEBES" é um departamento +-- distinto de "BEBIDAS"). Apenas "BEBIDAS" e "PADARIA" correspondem ao pedido. + +SELECT DISTINCT dp.DEP_NAME, dp.SECTION_NAME +FROM `looqbox-challenge`.data_product dp +WHERE dp.DEP_NAME IN ('BEBIDAS', 'PADARIA') +ORDER BY dp.DEP_NAME; diff --git a/sql_test/query3.sql b/sql_test/query3.sql new file mode 100644 index 0000000..d7d700b --- /dev/null +++ b/sql_test/query3.sql @@ -0,0 +1,17 @@ +-- Query 3 — Venda total (em $) por Business Area no 1º trimestre de 2019 +-- JOIN entre as vendas de produto (data_product_sales) e o cadastro de loja +-- (data_store_cad), que contém a Business Area. +-- Decisão técnica: STORE_CODE é varchar em data_product_sales e int em +-- data_store_cad. Apliquei CAST(... AS UNSIGNED) para alinhar os tipos no JOIN. +-- DATE é tipo DATE (sem hora), então BETWEEN inclui o dia 31/03 integralmente. +-- INNER JOIN é adequado: uma venda sem loja cadastrada não tem Business Area +-- atribuível, logo deve ficar de fora da agregação por área. + +SELECT dsc.BUSINESS_NAME, + SUM(dps.SALES_VALUE) AS TOTAL_VENDA +FROM `looqbox-challenge`.data_product_sales dps +JOIN `looqbox-challenge`.data_store_cad dsc + ON CAST(dps.STORE_CODE AS UNSIGNED) = dsc.STORE_CODE +WHERE dps.`DATE` BETWEEN '2019-01-01' AND '2019-03-31' +GROUP BY dsc.BUSINESS_NAME +ORDER BY TOTAL_VENDA DESC;