# Citrus — Sistema de Gestão de Diárias
## Spec 6: Módulo de Relatórios Dinâmicos — Documento de Design

| Campo | Valor |
| :---- | :---- |
| Projeto | Citrus Engenharia — Sistema de Gestão de Diárias |
| Spec | 6 de N (Relatórios dinâmicos) |
| Data | 2026-06-19 |
| Stack | Docker + PHP 8.2 + Yii2 + MySQL 8.0 + Bootstrap5 + PhpSpreadsheet |
| Branch | `citrus-relatorios` |
| Requisitos-fonte | `Citrus_Requisitos_v1.0.docx.md` §8 |
| Depende de | Spec 1 (Obra/Profissional/Funcao, BaseActiveRecord, PermissaoBehavior, ExportHelper), Spec 2 (Efetivo/EfetivoDia), Spec 4 (Card/CardItem, Quinzena) |

---

## 0. Contexto e escopo

Módulo de relatórios **dinâmico, alimentado por SQL**: o administrador cadastra queries no banco; a
view renderiza automaticamente as colunas retornadas, reconhecendo tipos (monetário, data, número,
texto); filtros declarados por relatório; toda listagem exporta para Excel.

**No escopo:** tabela `relatorio` (query + metadados), motor seguro de execução (`RelatorioRunner`),
controller/views (lista, filtros dinâmicos, tabela dinâmica), export Excel, console command para
cadastrar novos relatórios a partir de um arquivo de definição, e os **6 relatórios pré-definidos**
semeados via migration.

**Fora do escopo:** CRUD de relatórios pela web (não há tela para editar SQL na aplicação — reduz
superfície de risco); gráficos (Spec 5); agendamento/envio automático; cache de resultados.

Decisões do brainstorming:
- **Filtros via bind params + metadado**: a query usa params nomeados (`:obra_id`, `:data_ini`…) e o
  relatório declara cada filtro em `params` (JSON). A view monta o form e o `RelatorioRunner` liga via
  PDO — **nunca** interpolando string.
- **Tipos de coluna declarados** no metadado `colunas` (com fallback de detecção automática por
  nome/valor para colunas não declaradas).
- **Gestão**: sem UI de edição de SQL; os 6 relatórios são semeados por migration e novos são
  adicionados por um **console command** que lê um **arquivo de definição** (PHP retornando array).
- **Segurança**: somente `SELECT`/`WITH`, sem múltiplas instruções, palavras de escrita bloqueadas,
  parâmetros sempre via binding.

---

## 1. Modelo de dados

### `relatorio` (herda `BaseActiveRecord`)
| Coluna | Tipo | Observações |
| :-- | :-- | :-- |
| id | pk | |
| slug | string(80) | único entre não-excluídos; usado na URL/console |
| nome | string(120) | título exibido |
| descricao | string(255) null | |
| sql | text | a query (somente leitura) |
| params | text (JSON) | lista de filtros `[{nome, label, tipo}]` |
| colunas | text (JSON) null | tipos por coluna `{coluna: tipo}` (opcional) |
| ordem | int | ordenação na listagem (default 0) |
| status | ENUM(`ativo`,`inativo`) | default `ativo` |
| created_at / updated_at / deleted_at | int | |

**`params` (filtros)** — cada item `{ "nome": "obra_id", "label": "Obra", "tipo": "obra" }`. Tipos:
`obra` e `profissional` (Select2 dos cadastros ativos), `quinzena` (dropdown de `Quinzena::disponiveis()`),
`data` (input date), `texto`, `numero`. O `nome` corresponde ao placeholder `:nome` na SQL.

**`colunas` (tipos de saída)** — `{ "total_liquido": "monetario", "data": "data", "diarias": "numero" }`.
Tipos: `monetario`, `data`, `numero`, `texto`. Colunas ausentes usam detecção automática.

---

## 2. Motor seguro — `app\components\RelatorioRunner`

- **`validarSelect(string $sql): void`** — normaliza (trim, remove `;` final); exige começar com
  `SELECT` ou `WITH` (case-insensitive); rejeita `;` interno (múltiplas instruções), comentários
  (`--`, `/* */`) e as palavras de escrita `INSERT|UPDATE|DELETE|DROP|ALTER|TRUNCATE|CREATE|GRANT|
  REVOKE|REPLACE|RENAME|CALL|LOAD|INTO|SET ` (busca por palavra inteira). Lança `\RuntimeException`
  em violação.
- **`executar(Relatorio $r, array $valores): array`** — valida a SQL; monta o array de binding **apenas**
  com os params declarados em `$r->params` que estão presentes (whitelist; valores extras ignorados;
  filtro vazio vira `null`); roda `Yii::$app->db->createCommand($r->sql, $bindings)->queryAll()`.
  Retorna `['colunas' => string[], 'linhas' => array<array<string,mixed>>]`. Em erro de SQL,
  lança/propaga mensagem amigável (sem vazar SQL ao usuário final).
- **`tipoColuna(string $nome, $amostra, array $declarados): string`** — se `$declarados[$nome]` existe,
  usa; senão: nome casando `/valor|total|custo|liquido|salario|bonus|desconto|preco|montante/i` →
  `monetario`; `/^data|_data|data_|_em$|dia$/i` → `data`; amostra numérica → `numero`; senão `texto`.
- **`formatar($valor, string $tipo): string`** — `monetario` → `R$ 1.234,56`; `data` →
  `dd/mm/aaaa` (aceita `Y-m-d`/timestamp); `numero` → número localizado; `texto` → string crua.
  Usada tanto na tela quanto no Excel.

A query roda sob o mesmo usuário de banco da aplicação; a barreira é a validação SELECT-only + binding.

---

## 3. Controller e views — `app\controllers\RelatorioController` (substitui o stub)

- **`behaviors`**: `PermissaoBehavior` (tela `relatorio`, já semeada para administrativo) com
  `acaoMap` `['ver' => 'view', 'export' => 'export']`; `VerbFilter` não é necessário (tudo GET).
- **`actionIndex`**: lista os `relatorio` ativos (ordem, nome), cada um linkando para `ver`.
- **`actionVer(int $id)`**: carrega o relatório; renderiza o form de filtros (de `params`). Se não há
  params, ou ao submeter, executa via `RelatorioRunner` e renderiza a tabela dinâmica. Mantém os
  valores dos filtros na query string (GET) para o link de export.
- **`actionExport(int $id)`**: reexecuta com os mesmos filtros (GET) e chama
  `ExportHelper::download($nome, $colunas, $linhasFormatadas)` (cabeçalhos = nomes das colunas;
  células formatadas por tipo).
- **Views**: `index.php` (lista — tabela desktop / cards mobile), `ver.php` (cabeçalho + filtros +
  resultado), parciais `_filtros.php` (um input por param conforme o tipo) e `_tabela.php` (colunas
  dinâmicas, células formatadas, estado vazio "Nenhum resultado para os filtros").

---

## 4. Console command — `app\commands\RelatorioController`

Namespace de console (`app\commands`), distinto do controller web. Comandos:
- **`actionCriar(string $arquivo)`**: inclui um arquivo PHP que **retorna** um array
  `['slug','nome','descricao','sql','params','colunas','ordem']`; valida a SQL com
  `RelatorioRunner::validarSelect`; faz *upsert* por `slug` (cria ou atualiza). Imprime confirmação.
- **`actionListar()`**: lista slug · nome · status dos relatórios cadastrados.

Arquivo (em vez de args soltos) porque a SQL é multilinha e quebraria no shell. Um exemplo de arquivo
de definição acompanha o plano.

---

## 5. Migration — `create_relatorio` + seed dos 6

Cria a tabela e insere os 6 relatórios do §8. Filtros e colunas de cada um (SQL exato detalhado no
plano, conferido contra o schema):

| Relatório | slug | Filtros (`params`) | Colunas principais |
| :-- | :-- | :-- | :-- |
| Custo por Obra | `custo-por-obra` | Obra, Período (data_ini/data_fim) | Obra, Nº diárias, Total diárias (R$) |
| Produtividade por Profissional | `produtividade-profissional` | Profissional, Quinzena | Profissional, Dias trabalhados, Registros sem saída |
| Folha Quinzenal Consolidada | `folha-quinzenal` | Quinzena | Profissional, Diárias, Bônus, Descontos, Líquido (R$) |
| Histórico do Profissional | `historico-profissional` | Profissional, Período | Data, Obra, Entrada, Saída |
| Efetivo por Obra/Período | `efetivo-obra-periodo` | Obra, Período | Data, Profissional, Entrada, Saída, Observação |
| Anomalias e Inconsistências | `anomalias` | Período | Data, Profissional, Obra, Problema |

Os filtros de período (`data_ini`/`data_fim`) ligam em `efetivo_dia.data`; a quinzena liga em
`card.quinzena`. Todos respeitam soft-delete (`deleted_at IS NULL`).

---

## 6. Testes (Codeception, contra `citrus_test`)

- **Unit (`RelatorioRunner`)**: `validarSelect` aceita `SELECT`/`WITH` e rejeita `INSERT/UPDATE/DELETE/
  DROP`, `;` interno e comentário; `executar` liga só os params declarados (param extra é ignorado;
  filtro vazio vira null sem quebrar); `tipoColuna` (declarado vence; convenção de nome; número);
  `formatar` (monetário/data/número).
- **Functional (`RelatorioCest`)**: `relatorio/index` 200 com permissão e **403** sem; `ver` de um
  relatório seeded roda e mostra colunas/linhas; `export` retorna o content-type de planilha;
  rejeição de SQL não-SELECT é coberta no Unit (a UI não aceita SQL).
- **Migration**: o seed cria os 6 relatórios (contagem e slugs).

---

## 7. Arquivos

- Model: `models/Relatorio.php`.
- Serviço: `components/RelatorioRunner.php`.
- Controller web: `controllers/RelatorioController.php` (substitui o stub).
- Console: `commands/RelatorioController.php`.
- Views: `views/relatorio/index.php`, `ver.php`, `_filtros.php`, `_tabela.php`.
- Migration: `migrations/mYYMMDD_NNNNNN_create_relatorio_table.php` (+ seed dos 6).
- CSS: ajustes em `web/css/app.css` (apenas acréscimo).
- Exemplo de arquivo de definição para o console (em `docs/` ou no plano).

`main.php` **não é alterado**; se faltar o item de menu de Relatórios, o trecho é entregue no plano.

---

## 8. Considerações

- O risco central é executar SQL cadastrada; mitigado por: sem UI de edição na web (só seed/console),
  validação SELECT-only, bloqueio de múltiplas instruções e binding obrigatório de parâmetros.
- `Relatorio` herda `BaseActiveRecord` por consistência (soft-delete/auditoria), embora seja config.
- Próximo (após v1.0): possíveis cache de resultados, agendamento e novos relatórios via console.
