Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c4f573cd1 | ||
|
|
857fcd6a82 | ||
|
|
841dfadde5 |
@@ -2,40 +2,6 @@
|
|||||||
|
|
||||||
> Features avaliadas em 03/03/2026. Organizadas por categoria e esforço estimado.
|
> Features avaliadas em 03/03/2026. Organizadas por categoria e esforço estimado.
|
||||||
|
|
||||||
## Alta Prioridade
|
|
||||||
|
|
||||||
### F-01 — Exportar Resultado em CSV.
|
|
||||||
|
|
||||||
**Problema:** O único formato de exportação é PDF. Para processar os resultados em ferramentas externas (Excel, Power BI, sistemas ERP), o usuário precisa redigitar dados do PDF.
|
|
||||||
|
|
||||||
**Solução:** Botão "Exportar CSV" na tela de resultado, gerando dois arquivos (ou um com duas seções):
|
|
||||||
- `faltantes.csv`: `serie,documento_tipo,numero`
|
|
||||||
- `duplicatas.csv`: `serie,documento_tipo,numero,ocorrencias`
|
|
||||||
|
|
||||||
**Escopo técnico:**
|
|
||||||
- `use case` `exportar_csv(resultado: &ResultadoAnalise, caminho: &Path)`
|
|
||||||
- Trait `CsvExporter` análoga à `PdfGenerator` (opcional, para testabilidade)
|
|
||||||
- Botão na barra de ações da tela `resultado.rs`
|
|
||||||
- Dependência `csv` já está no `Cargo.toml`
|
|
||||||
|
|
||||||
**Esforço estimado:** Médio (2–3h)
|
|
||||||
**Impacto:** Alto — elimina retrabalho manual
|
|
||||||
|
|
||||||
**Observações:**
|
|
||||||
Se não for muito custoso para implementar, pode ser uma boa ideia adicionar uma essa função.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### F-02 — Validação de Tamanho de Arquivo (PRD RF01.3) (Implementado)
|
|
||||||
|
|
||||||
**Status:** Já implementado. Ambos `csv_reader.rs` e `xlsx_reader.rs` verificam o tamanho do arquivo
|
|
||||||
antes de qualquer leitura via constante `LIMITE_BYTES = 50 MB`, retornando `ErroArquivo::TamanhoExcedido`
|
|
||||||
se excedido — exibido como modal de erro pela UI.
|
|
||||||
|
|
||||||
**Esforço estimado:** Baixo (30min) — *concluído*
|
|
||||||
**Impacto:** Médio — evita travamentos inesperados
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Média Prioridade
|
## Média Prioridade
|
||||||
|
|
||||||
@@ -59,43 +25,6 @@ Verificar se já existe no codigo, pois na tela de configuração do Layout ele
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### F-04 — Busca por Número na Tela de Resultado (Util)
|
|
||||||
|
|
||||||
**Problema:** Com listas longas, o usuário precisa navegar páginas para verificar se um número específico está faltante ou duplicado.
|
|
||||||
|
|
||||||
**Solução:** Campo de busca no topo da tela de resultado. Ao digitar `1234`, destaca se a nota:
|
|
||||||
- está **faltante** (aparece na lista de faltantes)
|
|
||||||
- está **duplicada** (aparece na lista de duplicatas)
|
|
||||||
- está **presente** (está nas notas importadas)
|
|
||||||
- **não encontrada** (fora do intervalo conhecido)
|
|
||||||
|
|
||||||
**Escopo técnico:**
|
|
||||||
- Campo `filtro_numero: String` no `App`
|
|
||||||
- Busca em `resultado.faltantes_por_serie` e `resultado.duplicadas_por_serie`
|
|
||||||
- Para "presente": busca em `app.notas_importadas` (já mantido em memória)
|
|
||||||
- Banner de resultado no topo da tela
|
|
||||||
|
|
||||||
**Esforço estimado:** Médio (2–3h)
|
|
||||||
**Impacto:** Médio — uso diário em auditorias pontuais
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### F-05 — Recarregar Arquivo Sem Reconfigurar (Implementado)
|
|
||||||
|
|
||||||
**Problema:** Quando o usuário corrige o arquivo fonte e quer re-verificar, precisa navegar todo o fluxo novamente (selecionar arquivo → configurar colunas → analisar).
|
|
||||||
|
|
||||||
**Solução:** Botão "🔄 Reanalisar Arquivo" na tela de resultado que reimporta o mesmo caminho com o mesmo layout atual, sem nenhuma interação adicional.
|
|
||||||
|
|
||||||
**Escopo técnico:**
|
|
||||||
- Persistir `caminho_arquivo_atual: Option<PathBuf>` no `App` (já existe parcialmente como `nome_arquivo`)
|
|
||||||
- Reaproveitar o fluxo de `executar_importacao()` com os parâmetros atuais
|
|
||||||
- Botão na barra de ações da tela `resultado.rs`
|
|
||||||
|
|
||||||
**Esforço estimado:** Baixo (1h)
|
|
||||||
**Impacto:** Médio — elimina atrito no ciclo corrigir → verificar
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### F-06 — Auto-detecção de Delimitador CSV (Util)
|
### F-06 — Auto-detecção de Delimitador CSV (Util)
|
||||||
|
|
||||||
**Problema:** O usuário precisa saber antecipadamente qual delimitador o arquivo usa (`,`, `;`, `\t`). Arquivos gerados por diferentes sistemas variam.
|
**Problema:** O usuário precisa saber antecipadamente qual delimitador o arquivo usa (`,`, `;`, `\t`). Arquivos gerados por diferentes sistemas variam.
|
||||||
@@ -152,11 +81,6 @@ Verificar se já existe no codigo, pois na tela de configuração do Layout ele
|
|||||||
|
|
||||||
| ID | Feature | Esforço | Impacto | Prioridade | Status |
|
| ID | Feature | Esforço | Impacto | Prioridade | Status |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| F-01 | Exportar resultado em CSV | Médio | Alto | Alta |
|
|
||||||
| F-02 | Validação de tamanho de arquivo | Baixo | Médio | Alta |
|
|
||||||
| F-03 | Preview de colunas antes da análise | Alto | Alto | Média |
|
| F-03 | Preview de colunas antes da análise | Alto | Alto | Média |
|
||||||
| F-04 | Busca por número no resultado | Médio | Médio | Média |
|
|
||||||
| F-06 | Recarregar arquivo sem reconfigurar | Baixo | Médio | Média |
|
|
||||||
| F-07 | Auto-detecção de delimitador CSV | Médio | Baixo-Médio | Média |
|
| F-07 | Auto-detecção de delimitador CSV | Médio | Baixo-Médio | Média |
|
||||||
| F-08 | Auto-detecção de encoding CSV | Médio | Baixo | Baixa |
|
| F-08 | Auto-detecção de encoding CSV | Médio | Baixo | Baixa |
|
||||||
| F-09 | Agrupamento de faltantes no PDF | Baixo | Baixo-Médio | Baixa | ✅ Implementado |
|
|
||||||
|
|||||||
@@ -1,353 +0,0 @@
|
|||||||
# FIX — Salvar Layout no Banco de Dados
|
|
||||||
|
|
||||||
**Data:** 04/03/2026
|
|
||||||
**Versão do PRD:** 1.7
|
|
||||||
**Status:** Pendente de Implementação
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Sumário dos Problemas
|
|
||||||
|
|
||||||
| # | Severidade | Arquivo(s) | Descrição |
|
|
||||||
|---|-----------|------------|-----------|
|
|
||||||
| 1 | **Crítico** | `migrations.rs` | `migration_v3` sem transação — falha parcial deixa banco permanentemente quebrado |
|
|
||||||
| 2 | **Crítico** | `migrations.rs` | `schema_version` atualizada fora da transação das migrations |
|
|
||||||
| 3 | **Crítico** | `usecases/layouts.rs` | Erros SQL mapeados como `ErroLayout::JsonMalformado` — mensagem completamente enganosa |
|
|
||||||
| 4 | Moderado | `layout_repository.rs` | `delimitador` TAB salvo como byte de controle invisível no banco |
|
|
||||||
| 5 | Moderado | `usecases/layouts.rs` | `pos_numero` / `pos_serie` XLSX podem ser strings vazias — sem validação no fluxo de save da UI |
|
|
||||||
| 6 | Moderado | `layout_repository.rs` | Índices posicionais hardcoded em `row.get(N)` — quebra silenciosa se colunas do SELECT forem reordenadas |
|
|
||||||
| 7 | Menor | `usecases/layouts.rs` + `layout_repository.rs` | Renomear layout para nome já existente (UPDATE) gera `JsonMalformado` em vez de `NomeConflitante` |
|
|
||||||
| 8 | Menor | `connection.rs` | `SELECT 1` não detecta corrupção real de páginas SQLite |
|
|
||||||
| 9 | Menor | `layout_repository.rs` | UPDATE não zera campos do tipo oposto ao tipo atual do layout |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Problema 1 (Crítico) — `migration_v3` sem transação atômica
|
|
||||||
|
|
||||||
### Local
|
|
||||||
`src/infrastructure/sqlite/migrations.rs` — função `migration_v3`
|
|
||||||
|
|
||||||
### Situação Atual
|
|
||||||
```rust
|
|
||||||
fn migration_v3(conn: &Connection) -> Result<()> {
|
|
||||||
conn.execute_batch(
|
|
||||||
"ALTER TABLE layouts ADD COLUMN indice_documento_tipo INTEGER;
|
|
||||||
ALTER TABLE layouts ADD COLUMN pos_documento_tipo TEXT;",
|
|
||||||
)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`execute_batch` executa os dois `ALTER TABLE` sem transação explícita. Se o processo for interrompido após o primeiro e antes do segundo, o banco fica em estado parcial:
|
|
||||||
|
|
||||||
- `indice_documento_tipo` existe, `pos_documento_tipo` não existe.
|
|
||||||
- A versão no banco não é atualizada (o erro propaga antes).
|
|
||||||
- Na próxima execução, `migration_v3` tenta adicionar `indice_documento_tipo` novamente → SQLite retorna `"duplicate column name"` → **o app nunca mais inicializa sem intervenção manual**.
|
|
||||||
|
|
||||||
### Correção Necessária
|
|
||||||
Envolver cada migration em uma transação explícita e atualizar `schema_version` **dentro da mesma transação**:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
fn migration_v3(conn: &Connection) -> Result<()> {
|
|
||||||
conn.execute_batch("
|
|
||||||
BEGIN;
|
|
||||||
ALTER TABLE layouts ADD COLUMN indice_documento_tipo INTEGER;
|
|
||||||
ALTER TABLE layouts ADD COLUMN pos_documento_tipo TEXT;
|
|
||||||
UPDATE schema_version SET versao = 3;
|
|
||||||
COMMIT;
|
|
||||||
")?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Nota:** O SQLite suporta `ALTER TABLE` dentro de transação explícita desde a versão 3.x. `execute_batch` executa múltiplos statements quando delimitados por `;` dentro do mesmo bloco `BEGIN/COMMIT`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Problema 2 (Crítico) — `schema_version` atualizada fora da transação das migrations
|
|
||||||
|
|
||||||
### Local
|
|
||||||
`src/infrastructure/sqlite/migrations.rs` — função `aplicar_migrations`
|
|
||||||
|
|
||||||
### Situação Atual
|
|
||||||
O fluxo atual é:
|
|
||||||
1. Executa `migration_v1(conn)?`
|
|
||||||
2. Executa `migration_v2(conn)?`
|
|
||||||
3. Executa `migration_v3(conn)?`
|
|
||||||
4. **Depois** executa `INSERT OR REPLACE INTO schema_version ...`
|
|
||||||
|
|
||||||
Se qualquer migration falhar após outras já terem sido aplicadas, a versão não é atualizada, causando re-execução problemática na próxima abertura.
|
|
||||||
|
|
||||||
### Correção Necessária
|
|
||||||
Cada migration deve atualizar `schema_version` internamente (dentro de sua própria transação), como mostrado no Problema 1. A função `aplicar_migrations` não deve mais atualizar a versão centralmente — ela apenas chama as migrations que ainda não foram aplicadas.
|
|
||||||
|
|
||||||
Adicionalmente, as migrations v1 e v2 devem seguir o mesmo padrão:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
fn migration_v1(conn: &Connection) -> Result<()> {
|
|
||||||
conn.execute_batch("
|
|
||||||
BEGIN;
|
|
||||||
CREATE TABLE IF NOT EXISTS layouts ( ... );
|
|
||||||
INSERT OR REPLACE INTO schema_version (id, versao) VALUES (1, 1);
|
|
||||||
COMMIT;
|
|
||||||
")?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn migration_v2(conn: &Connection) -> Result<()> {
|
|
||||||
conn.execute_batch("
|
|
||||||
BEGIN;
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_layouts_nome ON layouts (nome);
|
|
||||||
-- renomeia duplicatas existentes se houver
|
|
||||||
UPDATE schema_version SET versao = 2;
|
|
||||||
COMMIT;
|
|
||||||
")?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Problema 3 (Crítico) — Erros SQL mapeados como `ErroLayout::JsonMalformado`
|
|
||||||
|
|
||||||
### Local
|
|
||||||
`src/application/usecases/layouts.rs` — função `salvar_layout` e `atualizar_layout`
|
|
||||||
|
|
||||||
### Situação Atual
|
|
||||||
```rust
|
|
||||||
layout_repository::salvar(conn, layout)
|
|
||||||
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
|
|
||||||
|
|
||||||
layout_repository::atualizar(conn, layout)
|
|
||||||
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
|
|
||||||
```
|
|
||||||
|
|
||||||
Qualquer erro do banco de dados (violação de constraint, coluna ausente, banco travado) é apresentado ao usuário como **"JSON malformado"**, o que é completamente incorreto. Exemplos de mensagens enganosas que o usuário veria:
|
|
||||||
|
|
||||||
- `"UNIQUE constraint failed: layouts.nome"` → exibido como "JSON malformado"
|
|
||||||
- `"no such column: pos_documento_tipo"` → exibido como "JSON malformado"
|
|
||||||
- `"attempt to write a readonly database"` → exibido como "JSON malformado"
|
|
||||||
|
|
||||||
### Correção Necessária
|
|
||||||
|
|
||||||
1. Adicionar variante própria em `domain/errors.rs`:
|
|
||||||
```rust
|
|
||||||
pub enum ErroLayout {
|
|
||||||
// ... variantes existentes ...
|
|
||||||
ErroBanco(String), // erros genéricos de I/O do banco
|
|
||||||
NomeConflitante, // já existe (para uso tanto em INSERT quanto em UPDATE)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. No use case, inspecionar o erro antes de mapear:
|
|
||||||
```rust
|
|
||||||
layout_repository::salvar(conn, layout)
|
|
||||||
.map_err(|e| {
|
|
||||||
let msg = e.to_string();
|
|
||||||
if msg.contains("UNIQUE constraint failed") {
|
|
||||||
ErroLayout::NomeConflitante
|
|
||||||
} else {
|
|
||||||
ErroLayout::ErroBanco(msg)
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Na UI (`ui/screens/layouts.rs`), tratar `ErroBanco` com mensagem clara ao usuário: `"Erro ao salvar no banco de dados: {mensagem}"`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Problema 4 (Moderado) — `delimitador` TAB salvo como byte de controle no banco
|
|
||||||
|
|
||||||
### Local
|
|
||||||
`src/infrastructure/sqlite/layout_repository.rs` — funções `salvar` e `atualizar` (CSV)
|
|
||||||
|
|
||||||
### Situação Atual
|
|
||||||
O `char` `'\t'` é convertido para `String` via `.to_string()`, armazenando um caractere de tabulação literal (byte `0x09`) no campo `TEXT` do banco. Embora funcional, é opaco para inspeção manual do banco e incompatível com exports/backups que não preservam bytes de controle.
|
|
||||||
|
|
||||||
### Correção Necessária
|
|
||||||
Normalizar o delimitador para um token legível antes de salvar:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// Ao salvar:
|
|
||||||
let delim_str = match config.delimitador {
|
|
||||||
'\t' => "tab".to_string(),
|
|
||||||
c => c.to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Ao ler:
|
|
||||||
let delimitador = match delim_str.as_str() {
|
|
||||||
"tab" => '\t',
|
|
||||||
s => s.chars().next().unwrap_or(';'),
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
> Isso também melhora a legibilidade dos arquivos JSON de export/import de layouts.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Problema 5 (Moderado) — `pos_numero` e `pos_serie` XLSX sem validação no fluxo de save da UI
|
|
||||||
|
|
||||||
### Local
|
|
||||||
`src/application/usecases/layouts.rs` — função `salvar_layout`
|
|
||||||
|
|
||||||
### Situação Atual
|
|
||||||
A validação atual verifica apenas que `nome` não esteja vazio. Os campos `pos_numero` e `pos_serie` de layouts XLSX são `String` obrigatórias (não `Option<String>`), mas podem ser strings vazias `""`. O banco os aceita sem restrição, e o erro só apareceria na análise, sem indicar que o layout está incompleto.
|
|
||||||
|
|
||||||
### Correção Necessária
|
|
||||||
Expandir a validação no use case `salvar_layout`:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
match layout {
|
|
||||||
Layout::Csv { config, .. } => {
|
|
||||||
// indice_numero e indice_serie são usize — sempre válidos se presentes
|
|
||||||
// nenhuma validação adicional necessária aqui
|
|
||||||
}
|
|
||||||
Layout::Xlsx { config, .. } => {
|
|
||||||
if config.pos_numero.trim().is_empty() {
|
|
||||||
return Err(ErroLayout::CampoObrigatorioAusente("pos_numero".to_string()));
|
|
||||||
}
|
|
||||||
if config.pos_serie.trim().is_empty() {
|
|
||||||
return Err(ErroLayout::CampoObrigatorioAusente("pos_serie".to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Problema 6 (Moderado) — Índices posicionais hardcoded em `row.get(N)`
|
|
||||||
|
|
||||||
### Local
|
|
||||||
`src/infrastructure/sqlite/layout_repository.rs` — função `listar`
|
|
||||||
|
|
||||||
### Situação Atual
|
|
||||||
O mapeamento usa índices numéricos (`row.get(0)`, `row.get(1)`, ..., `row.get(16)`). Qualquer reordenação das colunas no `SELECT` quebra silenciosamente o mapeamento sem erro de compilação.
|
|
||||||
|
|
||||||
### Correção Necessária
|
|
||||||
Substituir por nomes de colunas usando `row.get::<_, T>(nome_coluna)`:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// Em vez de:
|
|
||||||
let id: i64 = row.get(0)?;
|
|
||||||
let nome: String = row.get(1)?;
|
|
||||||
|
|
||||||
// Usar:
|
|
||||||
let id: i64 = row.get("id")?;
|
|
||||||
let nome: String = row.get("nome")?;
|
|
||||||
```
|
|
||||||
|
|
||||||
O `rusqlite` suporta `row.get("nome_coluna")` desde a versão 0.26. A versão usada no projeto é 0.32, portanto compatível.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Problema 7 (Menor) — Renomear para nome conflitante em UPDATE gera mensagem errada
|
|
||||||
|
|
||||||
### Local
|
|
||||||
`src/application/usecases/layouts.rs` + `src/infrastructure/sqlite/layout_repository.rs`
|
|
||||||
|
|
||||||
### Situação Atual
|
|
||||||
No fluxo de UPDATE (re-save de layout existente), não há verificação de conflito de nome antes de chamar `atualizar`. Se o usuário renomear um layout para um nome já em uso, a constraint `UNIQUE` do banco rejeita o UPDATE, o erro é mapeado como `JsonMalformado` (Problema 3 acima).
|
|
||||||
|
|
||||||
### Correção Necessária
|
|
||||||
No use case `atualizar_layout`, verificar se o novo nome conflita com outro layout (excluindo o próprio ID):
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// Verificar conflito excluindo o próprio registro
|
|
||||||
if layout_repository::existe_nome_excluindo_id(conn, layout.nome(), layout.id())? {
|
|
||||||
return Err(ErroLayout::NomeConflitante);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Adicionar função no repository:
|
|
||||||
```rust
|
|
||||||
pub fn existe_nome_excluindo_id(conn: &Connection, nome: &str, id: Option<i64>) -> Result<bool> {
|
|
||||||
match id {
|
|
||||||
Some(id) => {
|
|
||||||
let count: i64 = conn.query_row(
|
|
||||||
"SELECT COUNT(*) FROM layouts WHERE nome = ?1 AND id != ?2",
|
|
||||||
params![nome, id],
|
|
||||||
|row| row.get(0),
|
|
||||||
)?;
|
|
||||||
Ok(count > 0)
|
|
||||||
}
|
|
||||||
None => layout_repository::existe_nome(conn, nome),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Problema 8 (Menor) — `SELECT 1` não detecta corrupção real do banco
|
|
||||||
|
|
||||||
### Local
|
|
||||||
`src/infrastructure/sqlite/connection.rs`
|
|
||||||
|
|
||||||
### Situação Atual
|
|
||||||
```rust
|
|
||||||
match conn.execute_batch("SELECT 1;") {
|
|
||||||
Ok(_) => return Ok((conn, false)),
|
|
||||||
Err(_) => { /* trata como banco corrompido */ }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`SELECT 1` não acessa nenhuma página de dados do SQLite. Um banco com tabelas corrompidas, índices inválidos ou páginas com checksum errado passaria nessa verificação sem ser detectado, causando erros inesperados posteriormente.
|
|
||||||
|
|
||||||
### Correção Necessária
|
|
||||||
```rust
|
|
||||||
match conn.execute_batch("PRAGMA quick_check;") {
|
|
||||||
Ok(_) => return Ok((conn, false)),
|
|
||||||
Err(_) => { /* banco corrompido */ }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`PRAGMA quick_check` verifica a integridade estrutural do banco (sem verificar cada valor de dado, como `integrity_check` faz). É mais rápido que `integrity_check` e muito mais confiável que `SELECT 1`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Problema 9 (Menor) — UPDATE não zera campos do tipo oposto
|
|
||||||
|
|
||||||
### Local
|
|
||||||
`src/infrastructure/sqlite/layout_repository.rs` — função `atualizar`
|
|
||||||
|
|
||||||
### Situação Atual
|
|
||||||
O UPDATE de CSV não zera campos XLSX (`aba`, `pos_numero`, etc.) e o UPDATE de XLSX não zera campos CSV. Na prática não ocorre troca de tipo, mas se ocorrer (via importação JSON com mesmo nome e tipo diferente + sobrescrita), os campos do tipo anterior ficam no banco.
|
|
||||||
|
|
||||||
### Correção Necessária
|
|
||||||
Adicionar `SET campo = NULL` explícito para os campos do tipo oposto em cada UPDATE:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- UPDATE CSV: zerar campos XLSX
|
|
||||||
UPDATE layouts SET
|
|
||||||
nome = ?1, delimitador = ?2, encoding = ?3, linha_cabecalho = ?4,
|
|
||||||
indice_numero = ?5, indice_serie = ?6, indice_valor = ?7,
|
|
||||||
indice_data = ?8, indice_documento_tipo = ?9,
|
|
||||||
-- zerar campos XLSX:
|
|
||||||
aba = NULL, pos_numero = NULL, pos_serie = NULL,
|
|
||||||
pos_valor = NULL, pos_data = NULL, pos_documento_tipo = NULL
|
|
||||||
WHERE id = ?10
|
|
||||||
```
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- UPDATE XLSX: zerar campos CSV
|
|
||||||
UPDATE layouts SET
|
|
||||||
nome = ?1, aba = ?2, pos_numero = ?3, pos_serie = ?4,
|
|
||||||
pos_valor = ?5, pos_data = ?6, pos_documento_tipo = ?7,
|
|
||||||
-- zerar campos CSV:
|
|
||||||
delimitador = NULL, encoding = NULL, linha_cabecalho = NULL,
|
|
||||||
indice_numero = NULL, indice_serie = NULL, indice_valor = NULL,
|
|
||||||
indice_data = NULL, indice_documento_tipo = NULL
|
|
||||||
WHERE id = ?8
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ordem de Implementação Recomendada
|
|
||||||
|
|
||||||
1. **Problema 1 + 2** (migrations com transação) — Risco de banco permanentemente inutilizável; implementar primeiro.
|
|
||||||
2. **Problema 3** (mapeamento de erros) — Sem isso o usuário não entende o que está errado.
|
|
||||||
3. **Problema 5** (validação de campos XLSX) — Evita layouts inválidos serem salvos.
|
|
||||||
4. **Problema 7** (conflito de nome em UPDATE) — Depende da solução do Problema 3.
|
|
||||||
5. **Problema 4** (delimitador TAB) — Melhoria de robustez; não causa crash.
|
|
||||||
6. **Problema 6** (índices posicionais) — Refactor preventivo.
|
|
||||||
7. **Problema 8** (PRAGMA quick_check) — Melhoria de confiabilidade.
|
|
||||||
8. **Problema 9** (zerar campos opostos) — Limpeza defensiva.
|
|
||||||
@@ -5,6 +5,17 @@ use crate::domain::{
|
|||||||
use crate::infrastructure::sqlite::layout_repository;
|
use crate::infrastructure::sqlite::layout_repository;
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
|
|
||||||
|
/// Mapeia um erro rusqlite para `ErroLayout`, distinguindo conflito de nome
|
||||||
|
/// de erros genéricos de banco.
|
||||||
|
fn mapear_erro_banco(e: rusqlite::Error, nome: &str) -> ErroLayout {
|
||||||
|
let msg = e.to_string();
|
||||||
|
if msg.contains("UNIQUE constraint failed") {
|
||||||
|
ErroLayout::NomeConflitante(nome.to_string())
|
||||||
|
} else {
|
||||||
|
ErroLayout::ErroBanco(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Salva um layout no banco de dados.
|
/// Salva um layout no banco de dados.
|
||||||
/// Se o layout já tem um id, atualiza. Caso contrário, insere.
|
/// Se o layout já tem um id, atualiza. Caso contrário, insere.
|
||||||
/// Retorna `ErroLayout::NomeConflitante` se já existir um layout com o mesmo nome.
|
/// Retorna `ErroLayout::NomeConflitante` se já existir um layout com o mesmo nome.
|
||||||
@@ -14,19 +25,41 @@ pub fn salvar_layout(conn: &Connection, layout: &Layout) -> Result<i64, ErroLayo
|
|||||||
return Err(ErroLayout::CampoObrigatorioAusente("nome".to_string()));
|
return Err(ErroLayout::CampoObrigatorioAusente("nome".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validar campos XLSX obrigatórios
|
||||||
|
if let Layout::Xlsx { config, .. } = layout {
|
||||||
|
if config.pos_numero.trim().is_empty() {
|
||||||
|
return Err(ErroLayout::CampoObrigatorioAusente(
|
||||||
|
"pos_numero".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if config.pos_serie.trim().is_empty() {
|
||||||
|
return Err(ErroLayout::CampoObrigatorioAusente("pos_serie".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(id) = layout.id() {
|
if let Some(id) = layout.id() {
|
||||||
|
// Verificar conflito de nome com outro layout (excluindo o próprio)
|
||||||
|
layout_repository::existe_nome_excluindo_id(conn, layout.nome(), id)
|
||||||
|
.map_err(|e| ErroLayout::ErroBanco(e.to_string()))
|
||||||
|
.and_then(|conflito| {
|
||||||
|
if conflito {
|
||||||
|
Err(ErroLayout::NomeConflitante(layout.nome().to_string()))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
layout_repository::atualizar(conn, layout)
|
layout_repository::atualizar(conn, layout)
|
||||||
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
|
.map_err(|e| mapear_erro_banco(e, layout.nome()))?;
|
||||||
Ok(id)
|
Ok(id)
|
||||||
} else {
|
} else {
|
||||||
let nome = layout.nome().to_string();
|
let nome = layout.nome().to_string();
|
||||||
let existe = layout_repository::existe_nome(conn, &nome)
|
let existe = layout_repository::existe_nome(conn, &nome)
|
||||||
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
|
.map_err(|e| ErroLayout::ErroBanco(e.to_string()))?;
|
||||||
if existe {
|
if existe {
|
||||||
return Err(ErroLayout::NomeConflitante(nome));
|
return Err(ErroLayout::NomeConflitante(nome));
|
||||||
}
|
}
|
||||||
layout_repository::salvar(conn, layout)
|
layout_repository::salvar(conn, layout).map_err(|e| mapear_erro_banco(e, layout.nome()))
|
||||||
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,13 +112,13 @@ pub fn importar_layout_json(
|
|||||||
// Verificar conflito de nome
|
// Verificar conflito de nome
|
||||||
let nome_atual = layout.nome().to_string();
|
let nome_atual = layout.nome().to_string();
|
||||||
let existe = layout_repository::existe_nome(conn, &nome_atual)
|
let existe = layout_repository::existe_nome(conn, &nome_atual)
|
||||||
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
|
.map_err(|e| ErroLayout::ErroBanco(e.to_string()))?;
|
||||||
|
|
||||||
if existe {
|
if existe {
|
||||||
if sobrescrever_se_existir {
|
if sobrescrever_se_existir {
|
||||||
// Buscar o id existente para sobrescrever
|
// Buscar o id existente para sobrescrever
|
||||||
let layouts_existentes = layout_repository::listar(conn)
|
let layouts_existentes = layout_repository::listar(conn)
|
||||||
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
|
.map_err(|e| ErroLayout::ErroBanco(e.to_string()))?;
|
||||||
|
|
||||||
let id_existente = layouts_existentes
|
let id_existente = layouts_existentes
|
||||||
.iter()
|
.iter()
|
||||||
@@ -98,7 +131,7 @@ pub fn importar_layout_json(
|
|||||||
Layout::Xlsx { id: i, .. } => *i = Some(id),
|
Layout::Xlsx { id: i, .. } => *i = Some(id),
|
||||||
}
|
}
|
||||||
layout_repository::atualizar(conn, &layout)
|
layout_repository::atualizar(conn, &layout)
|
||||||
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
|
.map_err(|e| mapear_erro_banco(e, &nome_atual))?;
|
||||||
return Ok(id);
|
return Ok(id);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -107,5 +140,5 @@ pub fn importar_layout_json(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Inserir novo
|
// Inserir novo
|
||||||
layout_repository::salvar(conn, &layout).map_err(|e| ErroLayout::JsonMalformado(e.to_string()))
|
layout_repository::salvar(conn, &layout).map_err(|e| mapear_erro_banco(e, &nome_atual))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ pub enum ErroLayout {
|
|||||||
JsonMalformado(String),
|
JsonMalformado(String),
|
||||||
#[error("Conflito de nome: layout '{0}' já existe")]
|
#[error("Conflito de nome: layout '{0}' já existe")]
|
||||||
NomeConflitante(String),
|
NomeConflitante(String),
|
||||||
|
#[error("Erro no banco de dados: {0}")]
|
||||||
|
ErroBanco(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Error, Clone)]
|
#[derive(Debug, Error, Clone)]
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ Delega para `abrir_banco_no_caminho()` com o caminho padrão. Retorna
|
|||||||
`config.db.bak` e segue para a criação de um banco novo.
|
`config.db.bak` e segue para a criação de um banco novo.
|
||||||
4. Cria um banco vazio e retorna com flag `true` (banco foi recriado).
|
4. Cria um banco vazio e retorna com flag `true` (banco foi recriado).
|
||||||
|
|
||||||
|
> **Nota:** `SELECT 1` não acessa páginas de dados do SQLite e não detecta corrupção real. Um banco com tabelas ou índices corrompidos passaria nessa verificação. `PRAGMA quick_check` seria mais confiável (ver `docs/FIX_SALVAR_LAYOUT.md`, Problema 8).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## migrations.rs
|
## migrations.rs
|
||||||
@@ -88,7 +90,11 @@ Fluxo:
|
|||||||
- `versao_atual < 1` → `migration_v1`
|
- `versao_atual < 1` → `migration_v1`
|
||||||
- `versao_atual < 2` → `migration_v2`
|
- `versao_atual < 2` → `migration_v2`
|
||||||
- `versao_atual < 3` → `migration_v3`
|
- `versao_atual < 3` → `migration_v3`
|
||||||
4. Persiste a nova versão (`INSERT` se era `0`, `UPDATE` caso contrário).
|
4. Persiste a nova versão na tabela `schema_version`:
|
||||||
|
- `versao_atual == 0` → `INSERT INTO schema_version (versao) VALUES (3)`
|
||||||
|
- `versao_atual > 0 && versao_atual < 3` → `UPDATE schema_version SET versao = 3`
|
||||||
|
|
||||||
|
> **Atenção:** A atualização de `schema_version` ocorre **fora** das migrations individuais, após todas terem sido executadas. Isso significa que uma falha parcial entre migrations pode causar re-execução problemática na próxima inicialização (ver `docs/FIX_SALVAR_LAYOUT.md`, Problemas 1 e 2).
|
||||||
|
|
||||||
### Histórico de migrations
|
### Histórico de migrations
|
||||||
|
|
||||||
@@ -149,8 +155,10 @@ Insere um novo layout e retorna o `rowid` gerado.
|
|||||||
Atualiza um layout existente pelo `id` embutido na variante. Retorna erro se
|
Atualiza um layout existente pelo `id` embutido na variante. Retorna erro se
|
||||||
`id` for `None`.
|
`id` for `None`.
|
||||||
|
|
||||||
- `Layout::Csv` → atualiza apenas as colunas CSV.
|
- `Layout::Csv` → atualiza apenas as colunas CSV; colunas XLSX ficam inalteradas no banco.
|
||||||
- `Layout::Xlsx` → atualiza apenas as colunas XLSX.
|
- `Layout::Xlsx` → atualiza apenas as colunas XLSX; colunas CSV ficam inalteradas no banco.
|
||||||
|
|
||||||
|
> **Atenção:** O UPDATE não zera os campos do tipo oposto. Se um layout mudar de tipo (ex: via importação JSON com mesmo nome), os campos do tipo anterior persistem no banco (ver `docs/FIX_SALVAR_LAYOUT.md`, Problema 9).
|
||||||
|
|
||||||
#### `listar(conn) -> Result<Vec<Layout>>`
|
#### `listar(conn) -> Result<Vec<Layout>>`
|
||||||
|
|
||||||
@@ -161,6 +169,8 @@ Seleciona todos os layouts ordenados por `nome ASC`. Para cada linha:
|
|||||||
|
|
||||||
Campos opcionais (`Option<usize>`) são lidos como `Option<i64>` e convertidos.
|
Campos opcionais (`Option<usize>`) são lidos como `Option<i64>` e convertidos.
|
||||||
|
|
||||||
|
> **Atenção:** O mapeamento usa índices posicionais (`row.get(0)`, `row.get(1)`, etc.) e não nomes de colunas. Qualquer reordenação no `SELECT` quebra silenciosamente o resultado (ver `docs/FIX_SALVAR_LAYOUT.md`, Problema 6).
|
||||||
|
|
||||||
#### `excluir(conn, id) -> Result<()>`
|
#### `excluir(conn, id) -> Result<()>`
|
||||||
|
|
||||||
Remove o registro com o `id` informado via `DELETE`.
|
Remove o registro com o `id` informado via `DELETE`.
|
||||||
@@ -187,7 +197,7 @@ aplicar_migrations(conn)
|
|||||||
├─ cria schema_version se necessário
|
├─ cria schema_version se necessário
|
||||||
├─ lê versão atual
|
├─ lê versão atual
|
||||||
├─ executa migrations pendentes (v1 → v2 → v3)
|
├─ executa migrations pendentes (v1 → v2 → v3)
|
||||||
└─ grava versão final
|
└─ grava versão final (INSERT se versao=0, UPDATE se versao<3)
|
||||||
```
|
```
|
||||||
|
|
||||||
Após esse fluxo, a conexão está pronta para uso pelo `layout_repository`.
|
Após esse fluxo, a conexão está pronta para uso pelo `layout_repository`.
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ pub fn abrir_banco_no_caminho(caminho: &Path) -> Result<(Connection, bool), Stri
|
|||||||
if caminho.exists() {
|
if caminho.exists() {
|
||||||
match Connection::open(caminho) {
|
match Connection::open(caminho) {
|
||||||
Ok(conn) => {
|
Ok(conn) => {
|
||||||
// Testar se o banco funciona com uma query simples
|
// Testar integridade estrutural do banco
|
||||||
match conn.execute_batch("SELECT 1;") {
|
match conn.execute_batch("PRAGMA quick_check;") {
|
||||||
Ok(_) => return Ok((conn, false)),
|
Ok(_) => return Ok((conn, false)),
|
||||||
Err(_e) => {
|
Err(_e) => {
|
||||||
// Banco corrompido
|
// Banco corrompido
|
||||||
|
|||||||
@@ -1,6 +1,22 @@
|
|||||||
use crate::domain::entities::layout::{Layout, LayoutCsv, LayoutXlsx};
|
use crate::domain::entities::layout::{Layout, LayoutCsv, LayoutXlsx};
|
||||||
use rusqlite::{params, Connection, Result};
|
use rusqlite::{params, Connection, Result};
|
||||||
|
|
||||||
|
/// Converte o char delimitador para string legível no banco.
|
||||||
|
fn delim_para_str(c: char) -> String {
|
||||||
|
match c {
|
||||||
|
'\t' => "tab".to_string(),
|
||||||
|
c => c.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converte a string armazenada no banco de volta para char delimitador.
|
||||||
|
fn str_para_delim(s: &str) -> char {
|
||||||
|
match s {
|
||||||
|
"tab" => '\t',
|
||||||
|
s => s.chars().next().unwrap_or(';'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Salva um layout no banco. Retorna o id gerado.
|
/// Salva um layout no banco. Retorna o id gerado.
|
||||||
pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
|
pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
|
||||||
match layout {
|
match layout {
|
||||||
@@ -13,7 +29,7 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
|
|||||||
VALUES (?1, 'csv', ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
VALUES (?1, 'csv', ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||||
params![
|
params![
|
||||||
nome,
|
nome,
|
||||||
config.delimitador.to_string(),
|
delim_para_str(config.delimitador),
|
||||||
config.encoding,
|
config.encoding,
|
||||||
config.linha_cabecalho as i64,
|
config.linha_cabecalho as i64,
|
||||||
config.indice_numero as i64,
|
config.indice_numero as i64,
|
||||||
@@ -47,6 +63,7 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Atualiza um layout existente no banco.
|
/// Atualiza um layout existente no banco.
|
||||||
|
/// Zera explicitamente os campos do tipo oposto para evitar dados órfãos.
|
||||||
pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
|
pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
|
||||||
let id = layout
|
let id = layout
|
||||||
.id()
|
.id()
|
||||||
@@ -58,11 +75,13 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
|
|||||||
"UPDATE layouts SET
|
"UPDATE layouts SET
|
||||||
nome = ?1, delimitador = ?2, encoding = ?3,
|
nome = ?1, delimitador = ?2, encoding = ?3,
|
||||||
linha_cabecalho = ?4, indice_numero = ?5, indice_serie = ?6,
|
linha_cabecalho = ?4, indice_numero = ?5, indice_serie = ?6,
|
||||||
indice_valor = ?7, indice_data = ?8, indice_documento_tipo = ?9
|
indice_valor = ?7, indice_data = ?8, indice_documento_tipo = ?9,
|
||||||
|
aba = NULL, pos_numero = NULL, pos_serie = NULL,
|
||||||
|
pos_valor = NULL, pos_data = NULL, pos_documento_tipo = NULL
|
||||||
WHERE id = ?10",
|
WHERE id = ?10",
|
||||||
params![
|
params![
|
||||||
nome,
|
nome,
|
||||||
config.delimitador.to_string(),
|
delim_para_str(config.delimitador),
|
||||||
config.encoding,
|
config.encoding,
|
||||||
config.linha_cabecalho as i64,
|
config.linha_cabecalho as i64,
|
||||||
config.indice_numero as i64,
|
config.indice_numero as i64,
|
||||||
@@ -78,7 +97,10 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE layouts SET
|
"UPDATE layouts SET
|
||||||
nome = ?1, aba = ?2, pos_numero = ?3, pos_serie = ?4,
|
nome = ?1, aba = ?2, pos_numero = ?3, pos_serie = ?4,
|
||||||
pos_valor = ?5, pos_data = ?6, pos_documento_tipo = ?7
|
pos_valor = ?5, pos_data = ?6, pos_documento_tipo = ?7,
|
||||||
|
delimitador = NULL, encoding = NULL, linha_cabecalho = NULL,
|
||||||
|
indice_numero = NULL, indice_serie = NULL, indice_valor = NULL,
|
||||||
|
indice_data = NULL, indice_documento_tipo = NULL
|
||||||
WHERE id = ?8",
|
WHERE id = ?8",
|
||||||
params![
|
params![
|
||||||
nome,
|
nome,
|
||||||
@@ -109,25 +131,31 @@ pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
|
|||||||
|
|
||||||
let layouts: Result<Vec<Layout>> = stmt
|
let layouts: Result<Vec<Layout>> = stmt
|
||||||
.query_map([], |row| {
|
.query_map([], |row| {
|
||||||
let id: i64 = row.get(0)?;
|
let id: i64 = row.get("id")?;
|
||||||
let nome: String = row.get(1)?;
|
let nome: String = row.get("nome")?;
|
||||||
let tipo: String = row.get(2)?;
|
let tipo: String = row.get("tipo")?;
|
||||||
|
|
||||||
if tipo == "csv" {
|
if tipo == "csv" {
|
||||||
let delim_str: String = row.get(3)?;
|
let delim_str: String = row.get("delimitador")?;
|
||||||
let delimitador = delim_str.chars().next().unwrap_or(';');
|
let delimitador = str_para_delim(&delim_str);
|
||||||
Ok(Layout::Csv {
|
Ok(Layout::Csv {
|
||||||
id: Some(id),
|
id: Some(id),
|
||||||
nome,
|
nome,
|
||||||
config: LayoutCsv {
|
config: LayoutCsv {
|
||||||
delimitador,
|
delimitador,
|
||||||
encoding: row.get(4)?,
|
encoding: row.get("encoding")?,
|
||||||
linha_cabecalho: row.get::<_, i64>(5)? as usize,
|
linha_cabecalho: row.get::<_, i64>("linha_cabecalho")? as usize,
|
||||||
indice_numero: row.get::<_, i64>(6)? as usize,
|
indice_numero: row.get::<_, i64>("indice_numero")? as usize,
|
||||||
indice_serie: row.get::<_, i64>(7)? as usize,
|
indice_serie: row.get::<_, i64>("indice_serie")? as usize,
|
||||||
indice_valor: row.get::<_, Option<i64>>(8)?.map(|v| v as usize),
|
indice_valor: row
|
||||||
indice_data: row.get::<_, Option<i64>>(9)?.map(|v| v as usize),
|
.get::<_, Option<i64>>("indice_valor")?
|
||||||
indice_documento_tipo: row.get::<_, Option<i64>>(15)?.map(|v| v as usize),
|
.map(|v| v as usize),
|
||||||
|
indice_data: row
|
||||||
|
.get::<_, Option<i64>>("indice_data")?
|
||||||
|
.map(|v| v as usize),
|
||||||
|
indice_documento_tipo: row
|
||||||
|
.get::<_, Option<i64>>("indice_documento_tipo")?
|
||||||
|
.map(|v| v as usize),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -135,12 +163,12 @@ pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
|
|||||||
id: Some(id),
|
id: Some(id),
|
||||||
nome,
|
nome,
|
||||||
config: LayoutXlsx {
|
config: LayoutXlsx {
|
||||||
aba: row.get(10)?,
|
aba: row.get("aba")?,
|
||||||
pos_numero: row.get(11)?,
|
pos_numero: row.get("pos_numero")?,
|
||||||
pos_serie: row.get(12)?,
|
pos_serie: row.get("pos_serie")?,
|
||||||
pos_valor: row.get(13)?,
|
pos_valor: row.get("pos_valor")?,
|
||||||
pos_data: row.get(14)?,
|
pos_data: row.get("pos_data")?,
|
||||||
pos_documento_tipo: row.get(16)?,
|
pos_documento_tipo: row.get("pos_documento_tipo")?,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -165,3 +193,14 @@ pub fn existe_nome(conn: &Connection, nome: &str) -> Result<bool> {
|
|||||||
)?;
|
)?;
|
||||||
Ok(count > 0)
|
Ok(count > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Verifica se existe um layout com o nome fornecido, excluindo o registro com o id dado.
|
||||||
|
/// Usado para validar conflito de nome ao renomear um layout existente.
|
||||||
|
pub fn existe_nome_excluindo_id(conn: &Connection, nome: &str, id: i64) -> Result<bool> {
|
||||||
|
let count: i64 = conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM layouts WHERE nome = ?1 AND id != ?2",
|
||||||
|
params![nome, id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
Ok(count > 0)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
use rusqlite::{Connection, Result};
|
use rusqlite::{Connection, Result};
|
||||||
|
|
||||||
/// Versão atual do schema do banco de dados.
|
|
||||||
const VERSAO_SCHEMA_ATUAL: i64 = 3;
|
|
||||||
|
|
||||||
/// Aplica todas as migrations necessárias para atualizar o banco
|
/// Aplica todas as migrations necessárias para atualizar o banco
|
||||||
/// para a versão mais recente.
|
/// para a versão mais recente.
|
||||||
pub fn aplicar_migrations(conn: &Connection) -> Result<()> {
|
pub fn aplicar_migrations(conn: &Connection) -> Result<()> {
|
||||||
@@ -29,26 +26,17 @@ pub fn aplicar_migrations(conn: &Connection) -> Result<()> {
|
|||||||
migration_v3(conn)?;
|
migration_v3(conn)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if versao_atual == 0 {
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO schema_version (versao) VALUES (?1);",
|
|
||||||
[VERSAO_SCHEMA_ATUAL],
|
|
||||||
)?;
|
|
||||||
} else if versao_atual < VERSAO_SCHEMA_ATUAL {
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE schema_version SET versao = ?1;",
|
|
||||||
[VERSAO_SCHEMA_ATUAL],
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Migration v3: adicionar colunas de tipo de documento nos layouts.
|
/// Migration v3: adicionar colunas de tipo de documento nos layouts.
|
||||||
fn migration_v3(conn: &Connection) -> Result<()> {
|
fn migration_v3(conn: &Connection) -> Result<()> {
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
"ALTER TABLE layouts ADD COLUMN indice_documento_tipo INTEGER;
|
"BEGIN;
|
||||||
ALTER TABLE layouts ADD COLUMN pos_documento_tipo TEXT;",
|
ALTER TABLE layouts ADD COLUMN indice_documento_tipo INTEGER;
|
||||||
|
ALTER TABLE layouts ADD COLUMN pos_documento_tipo TEXT;
|
||||||
|
UPDATE schema_version SET versao = 3;
|
||||||
|
COMMIT;",
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -59,12 +47,15 @@ fn migration_v3(conn: &Connection) -> Result<()> {
|
|||||||
/// em bancos que já possuem nomes repetidos.
|
/// em bancos que já possuem nomes repetidos.
|
||||||
fn migration_v2(conn: &Connection) -> Result<()> {
|
fn migration_v2(conn: &Connection) -> Result<()> {
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
"UPDATE layouts
|
"BEGIN;
|
||||||
|
UPDATE layouts
|
||||||
SET nome = nome || ' (' || id || ')'
|
SET nome = nome || ' (' || id || ')'
|
||||||
WHERE id NOT IN (
|
WHERE id NOT IN (
|
||||||
SELECT MIN(id) FROM layouts GROUP BY nome
|
SELECT MIN(id) FROM layouts GROUP BY nome
|
||||||
);
|
);
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_layouts_nome ON layouts (nome);",
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_layouts_nome ON layouts (nome);
|
||||||
|
UPDATE schema_version SET versao = 2;
|
||||||
|
COMMIT;",
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -72,7 +63,8 @@ fn migration_v2(conn: &Connection) -> Result<()> {
|
|||||||
/// Migration v1: criar tabela de layouts.
|
/// Migration v1: criar tabela de layouts.
|
||||||
fn migration_v1(conn: &Connection) -> Result<()> {
|
fn migration_v1(conn: &Connection) -> Result<()> {
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
"CREATE TABLE IF NOT EXISTS layouts (
|
"BEGIN;
|
||||||
|
CREATE TABLE IF NOT EXISTS layouts (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
nome TEXT NOT NULL,
|
nome TEXT NOT NULL,
|
||||||
tipo TEXT NOT NULL CHECK(tipo IN ('csv', 'xlsx')),
|
tipo TEXT NOT NULL CHECK(tipo IN ('csv', 'xlsx')),
|
||||||
@@ -92,7 +84,9 @@ fn migration_v1(conn: &Connection) -> Result<()> {
|
|||||||
pos_serie TEXT,
|
pos_serie TEXT,
|
||||||
pos_valor TEXT,
|
pos_valor TEXT,
|
||||||
pos_data TEXT
|
pos_data TEXT
|
||||||
);",
|
);
|
||||||
|
INSERT INTO schema_version (versao) VALUES (1);
|
||||||
|
COMMIT;",
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-3
@@ -81,11 +81,19 @@ literais fora de `theme.rs`.**
|
|||||||
|
|
||||||
**Botões:**
|
**Botões:**
|
||||||
- `t::btn_primary` — azul sólido, ação principal
|
- `t::btn_primary` — azul sólido, ação principal
|
||||||
- `t::btn_secondary` — SURFACE_2, ação secundária
|
- `t::btn_secondary` — SURFACE_2 com borda `TEXT_SECONDARY` 55%, ação secundária
|
||||||
- `t::btn_ghost` — transparente com borda, ação terciária
|
- `t::btn_ghost` — transparente com borda `TEXT_SECONDARY` 55%, ação terciária
|
||||||
- `t::btn_danger` — vermelho semitransparente, exclusão
|
- `t::btn_danger` — vermelho semitransparente, exclusão
|
||||||
- `t::btn_aba_ativa` / `t::btn_aba_inativa` — seleção de aba XLSX
|
- `t::btn_aba_ativa` / `t::btn_aba_inativa` — seleção de aba XLSX
|
||||||
- `t::btn_pagina_ativo` / `t::btn_pagina_inativo` — paginação
|
- `t::btn_pagina_ativo` / `t::btn_pagina_inativo` — paginação
|
||||||
|
- `t::btn_breadcrumb_visitado` — botão sem fundo para passos anteriores do breadcrumb
|
||||||
|
|
||||||
|
### Contraste de estados de botão
|
||||||
|
|
||||||
|
`btn_secondary` e `btn_ghost` usam borda visível (`TEXT_SECONDARY` a 55% de opacidade)
|
||||||
|
no estado normal para se destacar de containers com fundo `SURFACE_2`. O estado
|
||||||
|
`Disabled` reduz a borda para 20% de opacidade, criando contraste claro entre ativo e
|
||||||
|
inativo. **Nunca remova a borda desses estilos** — sem ela os botões se perdem no fundo.
|
||||||
|
|
||||||
**Inputs:**
|
**Inputs:**
|
||||||
- `t::input_dark` — text_input com fundo BG, borda BORDER, focus PRIMARY
|
- `t::input_dark` — text_input com fundo BG, borda BORDER, focus PRIMARY
|
||||||
@@ -214,7 +222,7 @@ Chamado em `app.rs` quando `self.modal.is_some()`.
|
|||||||
Renderiza as primeiras N linhas do arquivo com cabeçalho estilo Excel (A, B, C...).
|
Renderiza as primeiras N linhas do arquivo com cabeçalho estilo Excel (A, B, C...).
|
||||||
|
|
||||||
- Células com `Fixed(100.0)` — tamanho fixo mínimo com scroll horizontal.
|
- Células com `Fixed(100.0)` — tamanho fixo mínimo com scroll horizontal.
|
||||||
- A altura da área de scroll está fixada em `Fixed(160.0)` — intencional.
|
- A altura da área de scroll está fixada em `Fixed(200.0)` — acomoda 5 linhas + cabeçalho + scrollbar horizontal.
|
||||||
- Scroll horizontal via `scrollable::Direction::Horizontal`.
|
- Scroll horizontal via `scrollable::Direction::Horizontal`.
|
||||||
|
|
||||||
### `paginacao::controles_paginacao(pagina, total, msg_anterior, msg_proxima)`
|
### `paginacao::controles_paginacao(pagina, total, msg_anterior, msg_proxima)`
|
||||||
@@ -244,6 +252,12 @@ diretamente de dentro de uma tela.**
|
|||||||
O breadcrumb é renderizado automaticamente por `app.rs` para todos os estados
|
O breadcrumb é renderizado automaticamente por `app.rs` para todos os estados
|
||||||
exceto `GerenciandoLayouts` e `Analisando`.
|
exceto `GerenciandoLayouts` e `Analisando`.
|
||||||
|
|
||||||
|
### Comportamento de navegação do breadcrumb
|
||||||
|
|
||||||
|
- **Passo ativo** — destaque azul com fundo semitransparente (`PRIMARY` a 15%).
|
||||||
|
- **Passos anteriores** — `button` com `t::btn_breadcrumb_visitado` e `on_press` com a mensagem de navegação correspondente (`Message::IrParaImportacao` para passo 1, `Message::IrParaConfiguracaoColunas` para passo 2). O hover revela fundo sutil + borda, sinalizando interatividade.
|
||||||
|
- **Passos futuros** — `container` com texto `TEXT_MUTED`, sem interação (não é possível pular à frente).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Adicionando uma nova tela
|
## 7. Adicionando uma nova tela
|
||||||
|
|||||||
+26
-14
@@ -14,7 +14,7 @@ use crate::domain::{
|
|||||||
use crate::infrastructure::sqlite::{connection::abrir_banco, migrations::aplicar_migrations};
|
use crate::infrastructure::sqlite::{connection::abrir_banco, migrations::aplicar_migrations};
|
||||||
use crate::ui::message::{Message, ResultadoPendente};
|
use crate::ui::message::{Message, ResultadoPendente};
|
||||||
use crate::ui::theme as t;
|
use crate::ui::theme as t;
|
||||||
use iced::widget::{column, container, text, Row};
|
use iced::widget::{button, column, container, text, Row};
|
||||||
use iced::{Alignment, Element, Length, Task};
|
use iced::{Alignment, Element, Length, Task};
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -614,9 +614,11 @@ impl App {
|
|||||||
let modal = self.modal.take();
|
let modal = self.modal.take();
|
||||||
if let Some(estado) = modal {
|
if let Some(estado) = modal {
|
||||||
match estado {
|
match estado {
|
||||||
EstadoModal::Confirmacao { acao, .. }
|
EstadoModal::Confirmacao { acao, .. } => {
|
||||||
| EstadoModal::InputTexto { acao, .. } => {
|
return self.executar_acao_modal(acao, None);
|
||||||
return self.executar_acao_modal(acao);
|
}
|
||||||
|
EstadoModal::InputTexto { acao, texto, .. } => {
|
||||||
|
return self.executar_acao_modal(acao, Some(texto));
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -786,6 +788,12 @@ impl App {
|
|||||||
let mut items: Vec<Element<'_, Message>> = Vec::new();
|
let mut items: Vec<Element<'_, Message>> = Vec::new();
|
||||||
|
|
||||||
for (i, (label, n)) in passos.iter().enumerate() {
|
for (i, (label, n)) in passos.iter().enumerate() {
|
||||||
|
let msg_nav = match n {
|
||||||
|
1 => Some(Message::IrParaImportacao),
|
||||||
|
2 => Some(Message::IrParaConfiguracaoColunas),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
let elem: Element<'_, Message> = if *n == passo_ativo {
|
let elem: Element<'_, Message> = if *n == passo_ativo {
|
||||||
container(text(*label).size(13).color(t::PRIMARY))
|
container(text(*label).size(13).color(t::PRIMARY))
|
||||||
.padding([4, 10])
|
.padding([4, 10])
|
||||||
@@ -796,9 +804,19 @@ impl App {
|
|||||||
})
|
})
|
||||||
.into()
|
.into()
|
||||||
} else if *n < passo_ativo {
|
} else if *n < passo_ativo {
|
||||||
text(*label).size(13).color(t::TEXT_SECONDARY).into()
|
// Passo já visitado — botão clicável
|
||||||
|
let mut btn = button(text(*label).size(13))
|
||||||
|
.padding([4, 10])
|
||||||
|
.style(t::btn_breadcrumb_visitado);
|
||||||
|
if let Some(msg) = msg_nav {
|
||||||
|
btn = btn.on_press(msg);
|
||||||
|
}
|
||||||
|
btn.into()
|
||||||
} else {
|
} else {
|
||||||
text(*label).size(13).color(t::TEXT_MUTED).into()
|
// Passo futuro — ainda não disponível
|
||||||
|
container(text(*label).size(13).color(t::TEXT_MUTED))
|
||||||
|
.padding([4, 10])
|
||||||
|
.into()
|
||||||
};
|
};
|
||||||
items.push(elem);
|
items.push(elem);
|
||||||
if i < passos.len() - 1 {
|
if i < passos.len() - 1 {
|
||||||
@@ -991,7 +1009,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn executar_acao_modal(&mut self, acao: AcaoModal) -> Task<Message> {
|
fn executar_acao_modal(&mut self, acao: AcaoModal, texto_input: Option<String>) -> Task<Message> {
|
||||||
match acao {
|
match acao {
|
||||||
AcaoModal::ConfirmarExpansaoFaltantes => {
|
AcaoModal::ConfirmarExpansaoFaltantes => {
|
||||||
self.update(Message::ConfirmarExpansaoFaltantes)
|
self.update(Message::ConfirmarExpansaoFaltantes)
|
||||||
@@ -1009,13 +1027,7 @@ impl App {
|
|||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
AcaoModal::SalvarLayoutConfig => {
|
AcaoModal::SalvarLayoutConfig => {
|
||||||
let texto = if let Some(EstadoModal::InputTexto { texto, .. }) = &self.modal {
|
let nome = texto_input.unwrap_or_default().trim().to_string();
|
||||||
texto.clone()
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
self.modal = None;
|
|
||||||
let nome = texto.trim().to_string();
|
|
||||||
if nome.is_empty() {
|
if nome.is_empty() {
|
||||||
self.exibir_erro("O nome do layout não pode ser vazio.");
|
self.exibir_erro("O nome do layout não pode ser vazio.");
|
||||||
return Task::none();
|
return Task::none();
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ pub fn tabela_preview(linhas: &[Vec<String>]) -> Element<'_, Message> {
|
|||||||
.direction(scrollable::Direction::Horizontal(
|
.direction(scrollable::Direction::Horizontal(
|
||||||
scrollable::Scrollbar::default(),
|
scrollable::Scrollbar::default(),
|
||||||
))
|
))
|
||||||
.height(Length::Fixed(160.0)),
|
.height(Length::Fixed(200.0)),
|
||||||
)
|
)
|
||||||
.style(|_theme| iced::widget::container::Style {
|
.style(|_theme| iced::widget::container::Style {
|
||||||
background: Some(t::SURFACE.into()),
|
background: Some(t::SURFACE.into()),
|
||||||
|
|||||||
+51
-5
@@ -288,8 +288,12 @@ pub fn btn_secondary(theme: &Theme, status: button::Status) -> button::Style {
|
|||||||
background: Some(SURFACE_2.into()),
|
background: Some(SURFACE_2.into()),
|
||||||
text_color: TEXT,
|
text_color: TEXT,
|
||||||
border: Border {
|
border: Border {
|
||||||
|
color: Color {
|
||||||
|
a: 0.55,
|
||||||
|
..TEXT_SECONDARY
|
||||||
|
},
|
||||||
|
width: 1.0,
|
||||||
radius: 6.0.into(),
|
radius: 6.0.into(),
|
||||||
..Default::default()
|
|
||||||
},
|
},
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -304,6 +308,10 @@ pub fn btn_secondary(theme: &Theme, status: button::Status) -> button::Style {
|
|||||||
}
|
}
|
||||||
.into(),
|
.into(),
|
||||||
),
|
),
|
||||||
|
border: Border {
|
||||||
|
color: TEXT_SECONDARY,
|
||||||
|
..base.border
|
||||||
|
},
|
||||||
..base
|
..base
|
||||||
},
|
},
|
||||||
button::Status::Disabled => button::Style {
|
button::Status::Disabled => button::Style {
|
||||||
@@ -315,6 +323,10 @@ pub fn btn_secondary(theme: &Theme, status: button::Status) -> button::Style {
|
|||||||
.into(),
|
.into(),
|
||||||
),
|
),
|
||||||
text_color: Color { a: 0.4, ..TEXT },
|
text_color: Color { a: 0.4, ..TEXT },
|
||||||
|
border: Border {
|
||||||
|
color: Color { a: 0.2, ..BORDER },
|
||||||
|
..base.border
|
||||||
|
},
|
||||||
..base
|
..base
|
||||||
},
|
},
|
||||||
_ => {
|
_ => {
|
||||||
@@ -324,13 +336,16 @@ pub fn btn_secondary(theme: &Theme, status: button::Status) -> button::Style {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Botão ghost (sem fundo, borda sutil)
|
/// Botão ghost (sem fundo, borda visível)
|
||||||
pub fn btn_ghost(theme: &Theme, status: button::Status) -> button::Style {
|
pub fn btn_ghost(theme: &Theme, status: button::Status) -> button::Style {
|
||||||
let base = button::Style {
|
let base = button::Style {
|
||||||
background: Some(Color::TRANSPARENT.into()),
|
background: Some(Color::TRANSPARENT.into()),
|
||||||
text_color: TEXT_SECONDARY,
|
text_color: TEXT,
|
||||||
border: Border {
|
border: Border {
|
||||||
color: BORDER,
|
color: Color {
|
||||||
|
a: 0.55,
|
||||||
|
..TEXT_SECONDARY
|
||||||
|
},
|
||||||
width: 1.0,
|
width: 1.0,
|
||||||
radius: 6.0.into(),
|
radius: 6.0.into(),
|
||||||
},
|
},
|
||||||
@@ -340,12 +355,16 @@ pub fn btn_ghost(theme: &Theme, status: button::Status) -> button::Style {
|
|||||||
button::Status::Hovered => button::Style {
|
button::Status::Hovered => button::Style {
|
||||||
background: Some(Color { a: 0.08, ..PRIMARY }.into()),
|
background: Some(Color { a: 0.08, ..PRIMARY }.into()),
|
||||||
text_color: TEXT,
|
text_color: TEXT,
|
||||||
|
border: Border {
|
||||||
|
color: PRIMARY,
|
||||||
|
..base.border
|
||||||
|
},
|
||||||
..base
|
..base
|
||||||
},
|
},
|
||||||
button::Status::Disabled => button::Style {
|
button::Status::Disabled => button::Style {
|
||||||
text_color: TEXT_MUTED,
|
text_color: TEXT_MUTED,
|
||||||
border: Border {
|
border: Border {
|
||||||
color: Color { a: 0.3, ..BORDER },
|
color: Color { a: 0.2, ..BORDER },
|
||||||
..base.border
|
..base.border
|
||||||
},
|
},
|
||||||
..base
|
..base
|
||||||
@@ -381,6 +400,33 @@ pub fn btn_danger(theme: &Theme, status: button::Status) -> button::Style {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Botão de passo anterior no breadcrumb (clicável, mas sem destaque)
|
||||||
|
pub fn btn_breadcrumb_visitado(_theme: &Theme, status: button::Status) -> button::Style {
|
||||||
|
let base = button::Style {
|
||||||
|
background: None,
|
||||||
|
text_color: TEXT_SECONDARY,
|
||||||
|
border: Border {
|
||||||
|
color: Color::TRANSPARENT,
|
||||||
|
width: 0.0,
|
||||||
|
radius: 4.0.into(),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
match status {
|
||||||
|
button::Status::Hovered => button::Style {
|
||||||
|
background: Some(Color { a: 0.08, ..TEXT }.into()),
|
||||||
|
text_color: TEXT,
|
||||||
|
border: Border {
|
||||||
|
color: Color { a: 0.15, ..TEXT },
|
||||||
|
width: 1.0,
|
||||||
|
radius: 4.0.into(),
|
||||||
|
},
|
||||||
|
..base
|
||||||
|
},
|
||||||
|
_ => base,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Botão de aba selecionada (tela selecionar_aba)
|
/// Botão de aba selecionada (tela selecionar_aba)
|
||||||
pub fn btn_aba_ativa(theme: &Theme, status: button::Status) -> button::Style {
|
pub fn btn_aba_ativa(theme: &Theme, status: button::Status) -> button::Style {
|
||||||
let base = button::Style {
|
let base = button::Style {
|
||||||
|
|||||||
Reference in New Issue
Block a user