Compare commits

..
2 Commits
Author SHA1 Message Date
FelipeCN 06eca335db update. 2026-03-04 17:27:02 -03:00
FelipeCN 6a5d941a00 Add roteiro para corrigir warnings. 2026-03-04 13:48:51 -03:00
14 changed files with 909 additions and 144 deletions
+8 -12
View File
@@ -26,17 +26,13 @@ Se não for muito custoso para implementar, pode ser uma boa ideia adicionar uma
--- ---
### F-02 — Validação de Tamanho de Arquivo (PRD RF01.3) (Implementar) ### F-02 — Validação de Tamanho de Arquivo (PRD RF01.3) (Implementado)
**Problema:** O PRD especifica limite de 50 MB, mas não há validação implementada. Arquivos muito grandes podem travar a UI durante a leitura. **Status:** 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.
**Solução:** Verificar `fs::metadata(caminho)?.len()` antes de iniciar importação. Exibir modal de erro se exceder o limite. **Esforço estimado:** Baixo (30min) — *concluído*
**Escopo técnico:**
- Adicionar checagem em `importar_arquivo.rs` antes de chamar CSV/XLSX reader
- Constante `LIMITE_ARQUIVO_BYTES: u64 = 50 * 1024 * 1024`
**Esforço estimado:** Baixo (30min)
**Impacto:** Médio — evita travamentos inesperados **Impacto:** Médio — evita travamentos inesperados
--- ---
@@ -154,8 +150,8 @@ Verificar se já existe no codigo, pois na tela de configuração do Layout ele
## Resumo ## Resumo
| ID | Feature | Esforço | Impacto | Prioridade | | ID | Feature | Esforço | Impacto | Prioridade | Status |
|---|---|---|---|---| |---|---|---|---|---|---|
| F-01 | Exportar resultado em CSV | Médio | Alto | Alta | | F-01 | Exportar resultado em CSV | Médio | Alto | Alta |
| F-02 | Validação de tamanho de arquivo | Baixo | Médio | 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 |
@@ -163,4 +159,4 @@ Verificar se já existe no codigo, pois na tela de configuração do Layout ele
| F-06 | Recarregar arquivo sem reconfigurar | Baixo | 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 | | F-09 | Agrupamento de faltantes no PDF | Baixo | Baixo-Médio | Baixa | ✅ Implementado |
+353
View File
@@ -0,0 +1,353 @@
# 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.
+217
View File
@@ -0,0 +1,217 @@
# Roteiro de Desenvolvimento — Comparador de Notas
**Gerado em:** 04/03/2026
**Baseado em:** PRD v1.7 + análise estática do código atual
---
## 1. Warnings do Compilador
São 7 warnings ativos (`cargo build`). Nenhum é crítico, mas todos devem ser eliminados para manter o código limpo.
### W-01 — `Nota.data` nunca lida
**Arquivo:** `src/domain/entities/nota.rs:17`
**Causa:** O campo `data: Option<NaiveDate>` é armazenado na struct `Nota`, mas nenhum código de negócio ou de UI o consome atualmente.
**Contexto PRD:** O campo Data é definido no modelo de dados (seção 5) como opcional. O PRD diz que é "exibida como informação adicional no relatório PDF. Não participa de nenhuma regra de validação ou cálculo."
**Solução:** Usar o campo `data` na geração do PDF (`pdf_generator.rs`), exibindo a data da nota nas seções de faltantes ou duplicatas quando disponível. Isso resolve o warning e implementa o requisito do PRD.
---
### W-02 — `IntervaloSerie.minimo` e `IntervaloSerie.maximo` nunca lidos
**Arquivo:** `src/domain/entities/resultado_analise.rs:23-24`
**Causa:** Os campos `minimo: u64` e `maximo: u64` existem na struct `IntervaloSerie`, mas nenhum código os consome após o cálculo.
**Contexto PRD:** RF04 define que o intervalo de faltantes é avaliado entre o menor e o maior número encontrado. O aviso de confirmação (RF04 — Proteção contra intervalos anormalmente grandes) exibe o intervalo calculado ao usuário.
**Solução:** Exibir `minimo` e `maximo` na mensagem de confirmação em `app.rs` quando o intervalo exceder 10.000. Exemplo: `"Série 001 / NFE: intervalo de 999.996 faltantes detectado (de 1 a 1.000.000)"`. O PRD especifica exatamente esse formato de aviso.
---
### W-03 — `ResultadoAnalise::sem_inconsistencias` nunca usada
**Arquivo:** `src/domain/entities/resultado_analise.rs:51`
**Causa:** O método `pub fn sem_inconsistencias()` está definido mas nunca chamado.
**Contexto PRD:** Nenhuma funcionalidade específica é mapeada diretamente para este método.
**Solução (opção A — remover):** Remover o método se não houver uso planejado próximo. É código morto.
**Solução (opção B — usar):** Usar o método na tela de resultado (`resultado.rs`) para exibir um badge "Sem inconsistências" ou mensagem de sucesso no topo quando `sem_inconsistencias()` for `true`. Melhora a UX e elimina o warning.
---
### W-04 — `EstadoModal::Informacao` nunca construída
**Arquivo:** `src/ui/app.rs:39`
**Causa:** A variante `Informacao { titulo, mensagem }` existe no enum `EstadoModal` mas nenhum código a instancia. Existe `Aviso` e `Erro` para as demais situações.
**Solução:** Remover a variante `Informacao` do enum se não houver distinção visual planejada entre "Informação" e "Aviso". Alternativamente, usá-la onde hoje se usa `Aviso` para casos puramente informativos (sem cor amarela de alerta). A variante mais simples é a remoção.
---
### W-05 — `Message::CancelarExpansao` nunca construída
**Arquivo:** `src/ui/message.rs:76`
**Causa:** A variante `CancelarExpansao` existe no enum `Message` e é tratada em `update()` (muda o estado para `ConfigurandoColunas`), mas nenhuma tela emite essa mensagem. O cancelamento do intervalo excessivo é feito via `Message::ModalCancelado`.
**Solução:** Remover `Message::CancelarExpansao` do enum. O comportamento de cancelar a expansão já está implementado em `Message::ModalCancelado` (`app.rs:629-636`).
---
### W-06 — `theme::cabecalho_tabela` nunca usada
**Arquivo:** `src/ui/theme.rs:140`
**Causa:** A função de estilo `cabecalho_tabela` está definida mas nenhuma tela a usa.
**Contexto:** O componente `tabela_preview.rs` usa estilo inline para o cabeçalho em vez desta função.
**Solução:** Aplicar `t::cabecalho_tabela` no cabeçalho da tabela de preview em `tabela_preview.rs`, substituindo o estilo inline atual. Isso centraliza o estilo e elimina o warning.
---
### W-07 — `theme::badge_sucesso` nunca usada
**Arquivo:** `src/ui/theme.rs:149`
**Causa:** A função de estilo `badge_sucesso` está definida mas nenhuma tela a usa. Os badges de sucesso são aplicados via `t::badge_aviso` (amarelo) ou texto colorido com `t::SUCCESS`.
**Contexto PRD:** A tela de resultado exibe indicador de completude por série. Quando 100% completo (sem faltantes), poderia exibir um badge verde.
**Solução (opção A — usar):** Exibir badge `t::badge_sucesso` na tela de resultado quando uma série não tem faltantes, em vez de apenas texto verde. Melhora a distinção visual.
**Solução (opção B — remover):** Remover se não houver uso planejado.
---
## 2. Funcionalidades Faltantes (vs. PRD v1.7)
### F-01 — Campo `data` no PDF (RF07.1)
**Status:** Não implementado
**PRD:** Seção 5 e RF07.1 — "Data de emissão da nota. Exibida como informação adicional no relatório PDF."
**Situação atual:** O campo `data` é lido do arquivo (`csv_reader.rs`, `xlsx_reader.rs`) e armazenado em `Nota.data`, mas `pdf_generator.rs` não o utiliza em nenhuma seção.
**O que falta:** Exibir a data de cada nota nas seções de faltantes e/ou duplicatas do PDF quando disponível. Por exemplo, no detalhamento de duplicatas: `"NF 42 / Série 001 — 3 ocorrências (última: 15/01/2025)"`.
---
### F-02 — Mensagem de confirmação com intervalo exato (RF04)
**Status:** Parcialmente implementado
**PRD:** RF04 — "Exibir aviso informando o intervalo calculado (ex: 'Série 001 / NFE: intervalo de 999.996 faltantes detectado')"
**Situação atual:** O aviso de confirmação em `app.rs:930-948` exibe apenas `"Série X: intervalo de N faltantes detectado"`, mas `minimo` e `maximo` de `IntervaloSerie` não são incluídos na mensagem. Os campos existem mas não são usados (W-02 acima).
**O que falta:** Incluir `minimo` e `maximo` na mensagem de confirmação para que o usuário veja o intervalo completo.
---
### F-03 — Limite de tamanho de arquivo 50 MB (RF01.3)
**Status:** Não implementado
**PRD:** RF01.3 — "O sistema deve recusar arquivos maiores que 50 MB e exibir mensagem de erro ao usuário."
**Situação atual:** Nenhuma verificação de tamanho de arquivo existe nos readers (`csv_reader.rs`, `xlsx_reader.rs`) nem em `processar_arquivo_selecionado` (`app.rs`).
**O que falta:** Verificar `std::fs::metadata(caminho)?.len()` antes de processar. Se > 50 MB, retornar `ErroArquivo::TamanhoExcedido` (o tipo já está definido em `domain/errors.rs`) e exibir modal de erro.
---
### F-04 — Arquivo XLSX corrompido: limpar estado (RF01.2)
**Status:** Parcialmente implementado
**PRD:** RF01.2 — "Se o arquivo não puder ser lido, exibir mensagem de erro em modal e limpar o arquivo carregado; o estado anterior é descartado."
**Situação atual:** `Message::XlsxErroAoCarregar` exibe o erro em modal, mas não limpa `self.caminho_arquivo` nem `self.nome_arquivo` nem `self.preview_arquivo`. O estado anterior do arquivo permanece em memória.
**O que falta:** No handler de `Message::XlsxErroAoCarregar`, zerar `self.caminho_arquivo = None`, `self.nome_arquivo = String::new()`, `self.preview_arquivo = None` e `self.notas_importadas.clear()` antes de exibir o erro.
---
### F-05 — Relatório de linhas malformadas ao usuário (RF01.1 / RF01.2)
**Status:** Parcialmente implementado
**PRD:** RF01.1/RF01.2 — "o sistema deve reportar ao usuário quais linhas foram descartadas, sem interromper a importação"
**Situação atual:** `ResumoAvisos` agrega contagens, e o modal de avisos exibe resumos do tipo "32 linhas descartadas por malformação". Porém, **os números de linha específicos** (ex: "linhas 15, 42, 103 descartadas") não são rastreados nem exibidos.
**O que falta:** Avaliar se o PRD exige listagem de números de linha individuais (o texto diz "quais linhas foram descartadas"). A interpretação atual (contagens por categoria) pode ser suficiente, mas merece revisão explícita com o product owner. Se linhas individuais forem necessárias, `ResumoAvisos` precisa armazenar `Vec<usize>` por categoria.
---
### F-06 — Validação de campos duplicados no mapeamento (RF02.3)
**Status:** Não implementado
**PRD:** RF02 — Tratamento de Erros de Configuração: "Dois campos mapeados para o mesmo índice/posição → Bloquear e exibir erro de validação imediatamente, antes de executar a análise"
**Situação atual:** `executar_importacao_sync` delega a validação ao reader, mas não há verificação explícita de índices/posições duplicados antes do disparo da análise. Se o usuário mapear, por exemplo, Numero e Serie para o mesmo índice CSV, os dados serão importados incorretamente sem aviso.
**O que falta:** Adicionar validação em `disparar_importacao` (ou no use case `importar_arquivo`) que verifique se algum índice (CSV) ou posição (XLSX) aparece em mais de um campo mapeado e retorne erro descritivo antes de iniciar o processamento.
---
### F-07 — Índice/posição inválido: identificar qual campo (RF02)
**Status:** Parcialmente implementado
**PRD:** RF02 — "Índice/posição configurado não existe no arquivo importado → Exibir erro ao usuário identificando qual campo está inválido"
**Situação atual:** Os readers retornam erros quando um índice não existe, mas a mensagem de erro pode não identificar claramente qual campo (Numero, Serie, Valor etc.) causou o problema.
**O que falta:** Garantir que as mensagens de erro de importação identifiquem o campo problemático pelo nome lógico (ex: "Campo 'Numero': índice 5 não existe — o arquivo tem 4 colunas").
---
### F-08 — Spinner/indicador visual durante análise (RNF03 / RNF04)
**Status:** Minimamente implementado
**PRD:** RNF03 — "A análise é executada em uma thread separada para não bloquear a interface gráfica"
**Situação atual:** O estado `EstadoApp::Analisando` exibe apenas dois textos estáticos ("Analisando..." e "Aguarde...") sem nenhum indicador de progresso animado.
**O que falta:** Implementar um spinner ou progress bar indeterminado na tela de análise para dar feedback visual ao usuário de que o processamento está ativo. O iced suporta animações via `Subscription` com `time::every`.
---
### F-09 — Paginação independente por série (RF07)
**Status:** Não implementado (limitação funcional)
**PRD:** RF07 — paginação para listas longas
**Situação atual:** `app.pagina_faltantes` e `app.pagina_duplicatas` são contadores globais únicos, compartilhados entre todas as séries. Ao navegar para a página 2 de faltantes da Série 001, a paginação também afeta a Série 002 se ambas aparecerem na mesma tela.
**O que falta:** Tornar a paginação independente por `ChaveSerie`, usando um `HashMap<ChaveSerie, usize>` no estado da aplicação em vez de um único `usize` global. Isso requer mudanças em `App`, em `Message` e na tela `resultado.rs`.
---
### F-10 — Exportação do resultado em CSV (seção 4.2 / seção 14)
**Status:** Não incluído no MVP (seção 4.2)
**PRD:** Listado como "Não Incluído (MVP)" na seção 4.2 e como evolução futura na seção 14.
**Nota:** Não é um requisito do MVP. Documentado aqui apenas para rastreabilidade.
---
## 3. Resumo por Prioridade
| ID | Tipo | Arquivo(s) afetado(s) | Esforço estimado |
|----|------|-----------------------|-----------------|
| W-01 | Warning → usar campo `data` | `pdf_generator.rs` | Baixo |
| W-02 | Warning → usar `minimo`/`maximo` | `app.rs` | Baixo |
| W-03 | Warning → remover ou usar | `resultado_analise.rs`, `resultado.rs` | Baixo |
| W-04 | Warning → remover variante | `app.rs` | Baixo |
| W-05 | Warning → remover variante | `message.rs`, `app.rs` | Baixo |
| W-06 | Warning → aplicar estilo | `tabela_preview.rs` | Baixo |
| W-07 | Warning → usar ou remover | `resultado.rs` ou `theme.rs` | Baixo |
| F-01 | Funcionalidade faltante | `pdf_generator.rs` | Baixo |
| F-02 | Funcionalidade incompleta | `app.rs` | Baixo |
| F-03 | Funcionalidade faltante | `csv_reader.rs`, `xlsx_reader.rs`, `app.rs` | Médio |
| F-04 | Funcionalidade incompleta | `app.rs` | Baixo |
| F-05 | Revisão de requisito | `domain/errors.rs`, readers | Médio |
| F-06 | Funcionalidade faltante | `app.rs` ou `importar_arquivo.rs` | Médio |
| F-07 | Funcionalidade incompleta | `csv_reader.rs`, `xlsx_reader.rs` | Médio |
| F-08 | UX faltante | `app.rs` | Médio |
| F-09 | Limitação funcional | `app.rs`, `message.rs`, `resultado.rs` | Alto |
| F-10 | Fora do MVP | — | — |
---
## 4. Ordem de Execução Sugerida
### Fase 1 — Warnings (todos baixo esforço, fazem parte da limpeza)
1. W-05: Remover `Message::CancelarExpansao`
2. W-04: Remover `EstadoModal::Informacao`
3. W-03: Decidir entre remover `sem_inconsistencias` ou usá-la em `resultado.rs`
4. W-07: Decidir entre remover `badge_sucesso` ou usá-la em `resultado.rs`
5. W-06: Aplicar `t::cabecalho_tabela` em `tabela_preview.rs`
6. W-02 + F-02: Usar `minimo`/`maximo` na mensagem de confirmação de intervalo
7. W-01 + F-01: Usar `data` no PDF
### Fase 2 — Bugs e requisitos críticos
8. F-04: Limpar estado ao falhar leitura XLSX
9. F-03: Validar limite de 50 MB
10. F-06: Validar campos duplicados no mapeamento
11. F-07: Mensagens de erro com nome do campo
### Fase 3 — Melhorias de UX
12. F-08: Spinner animado na tela de análise
13. F-09: Paginação independente por série
14. F-05: Revisar requisito de rastreamento de linhas individuais
---
*Fim do roteiro.*
+6 -4
View File
@@ -2,7 +2,7 @@ use crate::domain::{
entities::{ entities::{
chave_serie::ChaveSerie, chave_serie::ChaveSerie,
nota::Nota, nota::Nota,
resultado_analise::{ResultadoAnalise, ResultadoPreAnalise}, resultado_analise::{IntervaloSerie, ResultadoAnalise, ResultadoPreAnalise},
}, },
services::{ services::{
detector_duplicidade::duplicidades_por_serie, detector_duplicidade::duplicidades_por_serie,
@@ -56,12 +56,14 @@ pub fn pre_analisar(notas: &[Nota]) -> ResultadoPreAnalise {
} }
/// Verifica se algum grupo excede o limite de faltantes. /// Verifica se algum grupo excede o limite de faltantes.
/// Retorna lista de chaves que precisam de confirmação. /// Retorna lista de chaves com o intervalo completo para exibição ao usuário.
pub fn series_com_intervalo_excessivo(pre: &ResultadoPreAnalise) -> Vec<(ChaveSerie, u64)> { pub fn series_com_intervalo_excessivo(
pre: &ResultadoPreAnalise,
) -> Vec<(ChaveSerie, IntervaloSerie)> {
pre.intervalos_por_serie pre.intervalos_por_serie
.iter() .iter()
.filter(|(_, iv)| iv.excede_limite(LIMITE_FALTANTES)) .filter(|(_, iv)| iv.excede_limite(LIMITE_FALTANTES))
.map(|(chave, iv)| (chave.clone(), iv.contagem_faltantes)) .map(|(chave, iv)| (chave.clone(), iv.clone()))
.collect() .collect()
} }
+3 -1
View File
@@ -1,3 +1,4 @@
use crate::domain::entities::nota::Nota;
use crate::domain::entities::resultado_analise::ResultadoAnalise; use crate::domain::entities::resultado_analise::ResultadoAnalise;
use crate::infrastructure::pdf_generator::{MetadadosRelatorio, PdfGenerator}; use crate::infrastructure::pdf_generator::{MetadadosRelatorio, PdfGenerator};
use chrono::Local; use chrono::Local;
@@ -9,6 +10,7 @@ use std::path::Path;
pub fn exportar_pdf( pub fn exportar_pdf(
gerador: &dyn PdfGenerator, gerador: &dyn PdfGenerator,
resultado: &ResultadoAnalise, resultado: &ResultadoAnalise,
notas: &[Nota],
nome_arquivo: &str, nome_arquivo: &str,
nome_layout: Option<&str>, nome_layout: Option<&str>,
caminho_saida: &Path, caminho_saida: &Path,
@@ -19,5 +21,5 @@ pub fn exportar_pdf(
gerado_em: Local::now(), gerado_em: Local::now(),
}; };
gerador.gerar(resultado, &meta, caminho_saida) gerador.gerar(resultado, notas, &meta, caminho_saida)
} }
+36 -4
View File
@@ -44,7 +44,7 @@ pub fn importar_csv(
config.indice_valor, config.indice_valor,
config.indice_data, config.indice_data,
&mut avisos, &mut avisos,
); )?;
Ok(ResultadoImportacao { notas, avisos }) Ok(ResultadoImportacao { notas, avisos })
} }
@@ -102,13 +102,16 @@ pub fn importar_xlsx(
col_valor, col_valor,
col_data, col_data,
&mut avisos, &mut avisos,
); )?;
Ok(ResultadoImportacao { notas, avisos }) Ok(ResultadoImportacao { notas, avisos })
} }
/// Converte linhas brutas (Vec<Vec<String>>) em notas fiscais, /// Converte linhas brutas (Vec<Vec<String>>) em notas fiscais,
/// aplicando validações e coletando avisos. /// aplicando validações e coletando avisos.
///
/// Retorna `Err` se algum índice obrigatório ou opcional ativo excede o número de
/// colunas da primeira linha, identificando qual campo está inválido.
fn mapear_linhas_para_notas( fn mapear_linhas_para_notas(
linhas: &[Vec<String>], linhas: &[Vec<String>],
idx_numero: usize, idx_numero: usize,
@@ -117,9 +120,38 @@ fn mapear_linhas_para_notas(
idx_valor: Option<usize>, idx_valor: Option<usize>,
idx_data: Option<usize>, idx_data: Option<usize>,
avisos: &mut ResumoAvisos, avisos: &mut ResumoAvisos,
) -> Vec<Nota> { ) -> Result<Vec<Nota>, ErroArquivo> {
let mut notas = Vec::new(); let mut notas = Vec::new();
// Verificar se os índices existem na primeira linha disponível
if let Some(primeira_linha) = linhas.first() {
let total_colunas = primeira_linha.len();
let obrigatorios: &[(&str, usize)] = &[("Numero", idx_numero), ("Serie", idx_serie)];
for (nome, idx) in obrigatorios {
if *idx >= total_colunas {
return Err(ErroArquivo::ErroLeitura(format!(
"Campo '{}': índice {} não existe — o arquivo tem {} coluna(s).",
nome, idx, total_colunas
)));
}
}
let opcionais: &[(&str, Option<usize>)] = &[
("Valor", idx_valor),
("Data", idx_data),
("Tipo de Documento", idx_documento_tipo),
];
for (nome, maybe_idx) in opcionais {
if let Some(idx) = maybe_idx {
if *idx >= total_colunas {
return Err(ErroArquivo::ErroLeitura(format!(
"Campo '{}': índice {} não existe — o arquivo tem {} coluna(s).",
nome, idx, total_colunas
)));
}
}
}
}
for (i, linha) in linhas.iter().enumerate() { for (i, linha) in linhas.iter().enumerate() {
let linha_num = i + 1; let linha_num = i + 1;
@@ -216,7 +248,7 @@ fn mapear_linhas_para_notas(
notas.push(Nota::new(numero, serie, documento_tipo, valor, data)); notas.push(Nota::new(numero, serie, documento_tipo, valor, data));
} }
notas Ok(notas)
} }
/// Faz o parsing de um número, tentando extrair dígitos se necessário. /// Faz o parsing de um número, tentando extrair dígitos se necessário.
+35 -4
View File
@@ -1,11 +1,14 @@
use crate::domain::entities::chave_serie::ChaveSerie; use crate::domain::entities::chave_serie::ChaveSerie;
use crate::domain::entities::nota::Nota;
use crate::domain::entities::resultado_analise::ResultadoAnalise; use crate::domain::entities::resultado_analise::ResultadoAnalise;
use crate::domain::services::detector_sequencia::agrupar_contiguos;
use crate::domain::services::parser_monetario::formatar_valor_br; use crate::domain::services::parser_monetario::formatar_valor_br;
use chrono::{DateTime, Local}; use chrono::{DateTime, Local};
use genpdf::{ use genpdf::{
elements::{Break, Paragraph}, elements::{Break, Paragraph},
fonts, style, Document, SimplePageDecorator, fonts, style, Document, SimplePageDecorator,
}; };
use std::collections::HashMap;
use std::path::Path; use std::path::Path;
// Fontes embutidas no binário em tempo de compilação. // Fontes embutidas no binário em tempo de compilação.
@@ -28,6 +31,7 @@ pub trait PdfGenerator {
fn gerar( fn gerar(
&self, &self,
resultado: &ResultadoAnalise, resultado: &ResultadoAnalise,
notas: &[Nota],
meta: &MetadadosRelatorio, meta: &MetadadosRelatorio,
caminho_saida: &Path, caminho_saida: &Path,
) -> Result<(), String>; ) -> Result<(), String>;
@@ -40,9 +44,20 @@ impl PdfGenerator for GenpdfGenerator {
fn gerar( fn gerar(
&self, &self,
resultado: &ResultadoAnalise, resultado: &ResultadoAnalise,
notas: &[Nota],
meta: &MetadadosRelatorio, meta: &MetadadosRelatorio,
caminho_saida: &Path, caminho_saida: &Path,
) -> Result<(), String> { ) -> Result<(), String> {
// Construir mapa (chave_serie, numero) → data para uso na seção de duplicatas
let datas: HashMap<(ChaveSerie, u64), String> = notas
.iter()
.filter_map(|n| {
n.data.map(|d| {
let chave = ChaveSerie::new(n.serie.clone(), n.documento_tipo.clone());
((chave, n.numero), d.format("%d/%m/%Y").to_string())
})
})
.collect();
let font_family = carregar_fonte_familia()?; let font_family = carregar_fonte_familia()?;
let mut doc = Document::new(font_family); let mut doc = Document::new(font_family);
@@ -125,8 +140,19 @@ impl PdfGenerator for GenpdfGenerator {
chave.label(), chave.label(),
faltantes.len() faltantes.len()
))); )));
let numeros: Vec<String> = faltantes.iter().map(|n| n.to_string()).collect(); // Agrupar faltantes contíguos em intervalos (ex: 100104 em vez de 100, 101, 102, 103, 104)
doc.push(Paragraph::new(format!(" {}", numeros.join(", ")))); let grupos = agrupar_contiguos(faltantes);
let descricao: Vec<String> = grupos
.iter()
.map(|(ini, fim)| {
if ini == fim {
ini.to_string()
} else {
format!("{ini}{fim} ({} notas)", fim - ini + 1)
}
})
.collect();
doc.push(Paragraph::new(format!(" {}", descricao.join(", "))));
} }
doc.push(Break::new(1)); doc.push(Break::new(1));
@@ -158,11 +184,16 @@ impl PdfGenerator for GenpdfGenerator {
duplicatas.len() duplicatas.len()
))); )));
for (numero, count) in duplicatas { for (numero, count) in duplicatas {
let data_str = datas
.get(&((*chave).clone(), *numero))
.map(|d| format!(" (última: {})", d))
.unwrap_or_default();
doc.push(Paragraph::new(format!( doc.push(Paragraph::new(format!(
" NF {} / Série {}{} ocorrências", " NF {} / Série {}{} ocorrências{}",
numero, numero,
chave.label(), chave.label(),
count count,
data_str,
))); )));
} }
} }
+2 -2
View File
@@ -44,8 +44,6 @@ pub fn aplicar_migrations(conn: &Connection) -> Result<()> {
Ok(()) Ok(())
} }
/// Migration v2: adicionar índice único em layouts.nome.
/// 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(
@@ -55,6 +53,8 @@ fn migration_v3(conn: &Connection) -> Result<()> {
Ok(()) Ok(())
} }
/// Migration v2: adicionar índice único em layouts.nome.
///
/// Renomeia duplicatas (sufixo com id) antes de criar o índice para não falhar /// Renomeia duplicatas (sufixo com id) antes de criar o índice para não falhar
/// 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<()> {
+26 -45
View File
@@ -62,29 +62,7 @@ pub fn ler_xlsx(
continue; continue;
} }
let campos: Vec<String> = row let campos: Vec<String> = row.iter().map(celula_para_string).collect();
.iter()
.map(|cell| {
use calamine::Data;
match cell {
Data::Empty => String::new(),
Data::String(s) => s.clone(),
Data::Float(f) => {
if f.fract() == 0.0 {
format!("{}", *f as i64)
} else {
format!("{}", f)
}
}
Data::Int(i) => i.to_string(),
Data::Bool(b) => b.to_string(),
Data::DateTime(dt) => dt.to_string(),
Data::DateTimeIso(s) => s.clone(),
Data::DurationIso(s) => s.clone(),
Data::Error(_) => String::new(),
}
})
.collect();
// Ignorar linhas completamente em branco // Ignorar linhas completamente em branco
if campos.iter().all(|s: &String| s.trim().is_empty()) { if campos.iter().all(|s: &String| s.trim().is_empty()) {
@@ -113,28 +91,7 @@ pub fn preview_xlsx(caminho: &Path, nome_aba: &str) -> Result<Vec<Vec<String>>,
.rows() .rows()
.take(5) .take(5)
.map(|row| { .map(|row| {
row.iter() row.iter().map(celula_para_string).collect()
.map(|cell| {
use calamine::Data;
match cell {
Data::Empty => String::new(),
Data::String(s) => s.clone(),
Data::Float(f) => {
if f.fract() == 0.0 {
format!("{}", *f as i64)
} else {
format!("{}", f)
}
}
Data::Int(i) => i.to_string(),
Data::Bool(b) => b.to_string(),
Data::DateTime(dt) => dt.to_string(),
Data::DateTimeIso(s) => s.clone(),
Data::DurationIso(s) => s.clone(),
Data::Error(_) => String::new(),
}
})
.collect()
}) })
.collect(); .collect();
@@ -178,6 +135,30 @@ pub fn parsear_letra_linha(s: &str) -> Option<Coordenada> {
Some(Coordenada { coluna, linha }) Some(Coordenada { coluna, linha })
} }
/// Converte uma célula calamine em String, usando representação legível.
/// Inteiros sem parte decimal são formatados sem ".0"; erros e células vazias
/// retornam string vazia.
fn celula_para_string(cell: &calamine::Data) -> String {
use calamine::Data;
match cell {
Data::Empty => String::new(),
Data::String(s) => s.clone(),
Data::Float(f) => {
if f.fract() == 0.0 {
format!("{}", *f as i64)
} else {
format!("{}", f)
}
}
Data::Int(i) => i.to_string(),
Data::Bool(b) => b.to_string(),
Data::DateTime(dt) => dt.to_string(),
Data::DateTimeIso(s) => s.clone(),
Data::DurationIso(s) => s.clone(),
Data::Error(_) => String::new(),
}
}
fn verificar_tamanho(caminho: &Path) -> Result<(), ErroArquivo> { fn verificar_tamanho(caminho: &Path) -> Result<(), ErroArquivo> {
let metadata = let metadata =
std::fs::metadata(caminho).map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?; std::fs::metadata(caminho).map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
+145 -41
View File
@@ -9,7 +9,7 @@ use crate::domain::{
nota::Nota, nota::Nota,
resultado_analise::{ResultadoAnalise, ResultadoPreAnalise}, resultado_analise::{ResultadoAnalise, ResultadoPreAnalise},
}, },
errors::{ErroLayout, ResumoAvisos}, errors::{ErroArquivo, ErroLayout, ResumoAvisos},
}; };
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};
@@ -36,7 +36,6 @@ pub enum EstadoApp {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum EstadoModal { pub enum EstadoModal {
Informacao { titulo: String, mensagem: String },
Aviso { titulo: String, mensagem: String }, Aviso { titulo: String, mensagem: String },
Erro { titulo: String, mensagem: String }, Erro { titulo: String, mensagem: String },
Confirmacao { titulo: String, mensagem: String, acao: AcaoModal }, Confirmacao { titulo: String, mensagem: String, acao: AcaoModal },
@@ -82,9 +81,9 @@ pub struct App {
// Avisos da última importação // Avisos da última importação
pub avisos_importacao: Option<ResumoAvisos>, pub avisos_importacao: Option<ResumoAvisos>,
// Paginação // Paginação (independente por série)
pub pagina_faltantes: usize, pub pagina_faltantes: std::collections::HashMap<crate::domain::entities::chave_serie::ChaveSerie, usize>,
pub pagina_duplicatas: usize, pub pagina_duplicatas: std::collections::HashMap<crate::domain::entities::chave_serie::ChaveSerie, usize>,
pub itens_por_pagina: usize, pub itens_por_pagina: usize,
// Pré-visualização das primeiras linhas do arquivo // Pré-visualização das primeiras linhas do arquivo
@@ -95,6 +94,9 @@ pub struct App {
// Drag-and-drop: indica que um arquivo está sendo arrastado sobre a janela // Drag-and-drop: indica que um arquivo está sendo arrastado sobre a janela
pub arquivo_em_hover: bool, pub arquivo_em_hover: bool,
// Animação do spinner na tela de análise
pub spinner_frame: usize,
} }
impl Default for App { impl Default for App {
@@ -114,12 +116,13 @@ impl Default for App {
layouts_salvos: Vec::new(), layouts_salvos: Vec::new(),
modal: None, modal: None,
avisos_importacao: None, avisos_importacao: None,
pagina_faltantes: 0, pagina_faltantes: std::collections::HashMap::new(),
pagina_duplicatas: 0, pagina_duplicatas: std::collections::HashMap::new(),
itens_por_pagina: 100, itens_por_pagina: 100,
preview_arquivo: None, preview_arquivo: None,
resultado_anterior: None, resultado_anterior: None,
arquivo_em_hover: false, arquivo_em_hover: false,
spinner_frame: 0,
} }
} }
} }
@@ -210,7 +213,7 @@ impl App {
}, },
|result| match result { |result| match result {
Ok(layouts) => Message::LayoutsRecarregados(layouts), Ok(layouts) => Message::LayoutsRecarregados(layouts),
Err(e) => Message::ModalTextoAlterado(format!("Erro ao recarregar layouts: {}", e)), Err(e) => { eprintln!("Erro ao recarregar layouts: {e}"); Message::Noop },
}, },
); );
} }
@@ -452,12 +455,6 @@ impl App {
Task::none() Task::none()
} }
Message::CancelarExpansao => {
self.estado = EstadoApp::ConfigurandoColunas;
self.modal = None;
Task::none()
}
Message::NovaAnalise => { Message::NovaAnalise => {
self.exibir_confirmacao( self.exibir_confirmacao(
"Nova Análise", "Nova Análise",
@@ -473,18 +470,18 @@ impl App {
} }
// ── Resultado ── // ── Resultado ──
Message::PaginaFaltantesAlterada(p) => { Message::PaginaFaltantesAlterada(chave, p) => {
self.pagina_faltantes = p; self.pagina_faltantes.insert(chave, p);
Task::none() Task::none()
} }
Message::PaginaDuplicatasAlterada(p) => { Message::PaginaDuplicatasAlterada(chave, p) => {
self.pagina_duplicatas = p; self.pagina_duplicatas.insert(chave, p);
Task::none() Task::none()
} }
Message::ItensPorPaginaAlterado(n) => { Message::ItensPorPaginaAlterado(n) => {
self.itens_por_pagina = n; self.itens_por_pagina = n;
self.pagina_faltantes = 0; self.pagina_faltantes.clear();
self.pagina_duplicatas = 0; self.pagina_duplicatas.clear();
Task::none() Task::none()
} }
Message::CopiarFaltantes(chave) => { Message::CopiarFaltantes(chave) => {
@@ -659,6 +656,10 @@ impl App {
} }
Message::XlsxErroAoCarregar(e) => { Message::XlsxErroAoCarregar(e) => {
self.caminho_arquivo = None;
self.nome_arquivo = String::new();
self.preview_arquivo = None;
self.notas_importadas.clear();
self.exibir_erro(format!("Erro ao ler abas do arquivo: {}", e)); self.exibir_erro(format!("Erro ao ler abas do arquivo: {}", e));
Task::none() Task::none()
} }
@@ -679,6 +680,11 @@ impl App {
// ── Sem operação ── // ── Sem operação ──
Message::Noop => Task::none(), Message::Noop => Task::none(),
Message::SpinnerTick => {
self.spinner_frame = self.spinner_frame.wrapping_add(1);
Task::none()
}
} }
} }
@@ -697,10 +703,12 @@ impl App {
crate::ui::screens::resultado::view(self, resultado) crate::ui::screens::resultado::view(self, resultado)
} }
EstadoApp::GerenciandoLayouts => crate::ui::screens::layouts::view(self), EstadoApp::GerenciandoLayouts => crate::ui::screens::layouts::view(self),
EstadoApp::Analisando => { EstadoApp::Analisando => {
const FRAMES: &[&str] = &["", "", "", "", "", "", "", "", "", ""];
let frame = FRAMES[self.spinner_frame % FRAMES.len()];
container( container(
column![ column![
text("Analisando...").size(20).color(t::TEXT_SECONDARY), text(format!("{} Analisando...", frame)).size(20).color(t::TEXT_SECONDARY),
text("Aguarde enquanto o arquivo é processado.").size(14).color(t::TEXT_MUTED), text("Aguarde enquanto o arquivo é processado.").size(14).color(t::TEXT_MUTED),
] ]
.spacing(8) .spacing(8)
@@ -742,7 +750,7 @@ impl App {
// ─── Subscription (eventos de janela) ─────────────────────────────────── // ─── Subscription (eventos de janela) ───────────────────────────────────
pub fn subscription(&self) -> iced::Subscription<Message> { pub fn subscription(&self) -> iced::Subscription<Message> {
iced::event::listen_with(|event, _status, _id| match event { let drag_drop = iced::event::listen_with(|event, _status, _id| match event {
iced::Event::Window(iced::window::Event::FileDropped(path)) => { iced::Event::Window(iced::window::Event::FileDropped(path)) => {
Some(Message::ArquivoSolto(path)) Some(Message::ArquivoSolto(path))
} }
@@ -753,7 +761,15 @@ impl App {
Some(Message::ArquivoHoverSaiu) Some(Message::ArquivoHoverSaiu)
} }
_ => None, _ => None,
}) });
if matches!(self.estado, EstadoApp::Analisando) {
let spinner = iced::time::every(std::time::Duration::from_millis(100))
.map(|_| Message::SpinnerTick);
iced::Subscription::batch([drag_drop, spinner])
} else {
drag_drop
}
} }
// ─── Breadcrumb ────────────────────────────────────────────────────────── // ─── Breadcrumb ──────────────────────────────────────────────────────────
@@ -862,6 +878,12 @@ impl App {
let layout_csv = self.layout_csv_atual.clone(); let layout_csv = self.layout_csv_atual.clone();
let layout_xlsx = self.layout_xlsx_atual.clone(); let layout_xlsx = self.layout_xlsx_atual.clone();
// Validação de campos duplicados no mapeamento (RF02.3)
if let Some(erro) = validar_mapeamento_duplicado(&tipo, &layout_csv, &layout_xlsx) {
self.exibir_erro(erro);
return Task::none();
}
self.estado = EstadoApp::Analisando; self.estado = EstadoApp::Analisando;
Task::perform( Task::perform(
@@ -929,11 +951,13 @@ impl App {
self.avisos_importacao = if avisos.tem_avisos() { Some(avisos) } else { None }; self.avisos_importacao = if avisos.tem_avisos() { Some(avisos) } else { None };
let msg = series_excessivas let msg = series_excessivas
.iter() .iter()
.map(|(chave, count)| { .map(|(chave, iv)| {
format!( format!(
"Série {}: intervalo de {} faltantes detectado", "Série {}: intervalo de {} faltantes detectado (de {} a {})",
chave.label(), chave.label(),
count iv.contagem_faltantes,
iv.minimo,
iv.maximo,
) )
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
@@ -1072,6 +1096,7 @@ impl App {
} else { } else {
Some(self.nome_layout_atual.clone()) Some(self.nome_layout_atual.clone())
}; };
let notas = self.notas_importadas.clone();
Task::perform( Task::perform(
async move { async move {
@@ -1094,6 +1119,7 @@ impl App {
exportar_pdf( exportar_pdf(
&gerador, &gerador,
&resultado, &resultado,
&notas,
&nome_arquivo, &nome_arquivo,
nome_layout.as_deref(), nome_layout.as_deref(),
&caminho, &caminho,
@@ -1122,10 +1148,10 @@ impl App {
let (conteudo, nome_sugerido) = match exportar_layout_json(&layout) { let (conteudo, nome_sugerido) = match exportar_layout_json(&layout) {
Ok(r) => r, Ok(r) => r,
Err(e) => { Err(e) => {
// Não temos &mut self aqui; retornamos um command que exibe erro // Não temos &mut self aqui reutilizamos PdfExportado(Err) para
return Task::perform(async move { e.to_string() }, |e| { // exibir o modal de erro (o handler chama self.exibir_erro).
Message::PdfExportado(Err(e)) let msg = format!("Erro ao exportar layout JSON: {}", e);
}); return Task::perform(async move { msg }, |e| Message::PdfExportado(Err(e)));
} }
}; };
@@ -1150,7 +1176,8 @@ impl App {
|result| match result { |result| match result {
Ok(_) => Message::Noop, Ok(_) => Message::Noop,
Err(e) if e.is_empty() => Message::Noop, Err(e) if e.is_empty() => Message::Noop,
Err(e) => Message::PdfExportado(Err(format!("Erro ao salvar JSON: {}", e))), // Reutiliza PdfExportado(Err) para exibir modal de erro.
Err(e) => Message::PdfExportado(Err(format!("Erro ao salvar layout JSON: {}", e))),
}, },
) )
} }
@@ -1212,15 +1239,80 @@ impl App {
} }
} }
// ─── Mensagens extras para o fluxo XLSX ─────────────────────────────────────
// (precisamos adicionar estas variantes ao enum Message)
// Nota: as variantes AbaxlsxCarregadas e XlsxErroAoCarregar serão tratadas
// no match do update() principal via um segundo match arm de Message.
// Para isso, precisamos adicionar estas variantes ao message.rs também.
// ─── Função auxiliar síncrona para executar importação em spawn_blocking ───── // ─── Função auxiliar síncrona para executar importação em spawn_blocking ─────
/// Verifica se algum par de campos mapeados aponta para o mesmo índice (CSV)
/// ou posição (XLSX). Retorna uma mensagem de erro descritiva se houver conflito.
fn validar_mapeamento_duplicado(
tipo: &TipoArquivo,
layout_csv: &LayoutCsv,
layout_xlsx: &LayoutXlsx,
) -> Option<String> {
match tipo {
TipoArquivo::Csv => {
// Campos obrigatórios + opcionais ativos
let mut campos: Vec<(&str, usize)> = vec![
("Numero", layout_csv.indice_numero),
("Serie", layout_csv.indice_serie),
];
if let Some(i) = layout_csv.indice_valor {
campos.push(("Valor", i));
}
if let Some(i) = layout_csv.indice_data {
campos.push(("Data", i));
}
if let Some(i) = layout_csv.indice_documento_tipo {
campos.push(("Tipo de Documento", i));
}
// Verificar duplicatas
for i in 0..campos.len() {
for j in (i + 1)..campos.len() {
if campos[i].1 == campos[j].1 {
return Some(format!(
"Mapeamento inválido: campos '{}' e '{}' apontam para o mesmo índice ({}).",
campos[i].0, campos[j].0, campos[i].1
));
}
}
}
None
}
TipoArquivo::Xlsx => {
let mut campos: Vec<(&str, &str)> = vec![
("Numero", &layout_xlsx.pos_numero),
("Serie", &layout_xlsx.pos_serie),
];
if let Some(ref p) = layout_xlsx.pos_valor {
if !p.is_empty() {
campos.push(("Valor", p));
}
}
if let Some(ref p) = layout_xlsx.pos_data {
if !p.is_empty() {
campos.push(("Data", p));
}
}
if let Some(ref p) = layout_xlsx.pos_documento_tipo {
if !p.is_empty() {
campos.push(("Tipo de Documento", p));
}
}
// Verificar duplicatas (comparação case-insensitive)
for i in 0..campos.len() {
for j in (i + 1)..campos.len() {
if campos[i].1.to_uppercase() == campos[j].1.to_uppercase() {
return Some(format!(
"Mapeamento inválido: campos '{}' e '{}' apontam para a mesma posição ('{}').",
campos[i].0, campos[j].0, campos[i].1
));
}
}
}
None
}
}
}
fn executar_importacao_sync( fn executar_importacao_sync(
caminho: PathBuf, caminho: PathBuf,
tipo: TipoArquivo, tipo: TipoArquivo,
@@ -1228,8 +1320,20 @@ fn executar_importacao_sync(
layout_xlsx: LayoutXlsx, layout_xlsx: LayoutXlsx,
) -> ResultadoPendente { ) -> ResultadoPendente {
let res_importacao = match tipo { let res_importacao = match tipo {
TipoArquivo::Csv => importar_csv(&caminho, &layout_csv).map_err(|e| e.to_string()), TipoArquivo::Csv => importar_csv(&caminho, &layout_csv).map_err(|e| match e {
TipoArquivo::Xlsx => importar_xlsx(&caminho, &layout_xlsx).map_err(|e| e.to_string()), ErroArquivo::TamanhoExcedido(bytes) => format!(
"Arquivo muito grande ({:.1} MB). O limite é 50 MB.",
bytes as f64 / (1024.0 * 1024.0)
),
other => other.to_string(),
}),
TipoArquivo::Xlsx => importar_xlsx(&caminho, &layout_xlsx).map_err(|e| match e {
ErroArquivo::TamanhoExcedido(bytes) => format!(
"Arquivo muito grande ({:.1} MB). O limite é 50 MB.",
bytes as f64 / (1024.0 * 1024.0)
),
other => other.to_string(),
}),
}; };
match res_importacao { match res_importacao {
-3
View File
@@ -28,9 +28,6 @@ pub fn view_com_modal<'a>(
fn view_modal(modal: &EstadoModal) -> Element<'_, Message> { fn view_modal(modal: &EstadoModal) -> Element<'_, Message> {
match modal { match modal {
EstadoModal::Informacao { titulo, mensagem } => {
caixa_modal(titulo, mensagem, TipoModal::Info, None, false)
}
EstadoModal::Aviso { titulo, mensagem } => { EstadoModal::Aviso { titulo, mensagem } => {
caixa_modal(titulo, mensagem, TipoModal::Aviso, None, false) caixa_modal(titulo, mensagem, TipoModal::Aviso, None, false)
} }
+2 -10
View File
@@ -12,7 +12,7 @@ pub fn tabela_preview(linhas: &[Vec<String>]) -> Element<'_, Message> {
return text("(vazio)").size(12).color(t::TEXT_MUTED).into(); return text("(vazio)").size(12).color(t::TEXT_MUTED).into();
} }
// Cabeçalho estilo Excel — fundo SURFACE_2 // Cabeçalho estilo Excel — usa t::cabecalho_tabela centralizado
let cabecalho = container( let cabecalho = container(
row((0..num_colunas) row((0..num_colunas)
.map(|i| { .map(|i| {
@@ -30,15 +30,7 @@ pub fn tabela_preview(linhas: &[Vec<String>]) -> Element<'_, Message> {
.spacing(0), .spacing(0),
) )
.width(Length::Shrink) .width(Length::Shrink)
.style(|_theme| iced::widget::container::Style { .style(t::cabecalho_tabela);
background: Some(t::SURFACE_2.into()),
border: Border {
color: t::BORDER,
width: 0.0,
radius: 0.0.into(),
},
..Default::default()
});
let linhas_view = linhas.iter().enumerate().map(|(idx, linha)| { let linhas_view = linhas.iter().enumerate().map(|(idx, linha)| {
// Linhas alternadas: SURFACE e SURFACE_2 levemente // Linhas alternadas: SURFACE e SURFACE_2 levemente
+7 -5
View File
@@ -2,7 +2,7 @@ use crate::domain::entities::nota::Nota;
use crate::domain::entities::{ use crate::domain::entities::{
chave_serie::ChaveSerie, chave_serie::ChaveSerie,
layout::{Layout, LayoutXlsx}, layout::{Layout, LayoutXlsx},
resultado_analise::{ResultadoAnalise, ResultadoPreAnalise}, resultado_analise::{IntervaloSerie, ResultadoAnalise, ResultadoPreAnalise},
}; };
use crate::domain::errors::ResumoAvisos; use crate::domain::errors::ResumoAvisos;
use rusqlite::Connection; use rusqlite::Connection;
@@ -73,12 +73,11 @@ pub enum Message {
ExecutarImportacao, ExecutarImportacao,
ReanalisarArquivo, ReanalisarArquivo,
ConfirmarExpansaoFaltantes, ConfirmarExpansaoFaltantes,
CancelarExpansao,
NovaAnalise, NovaAnalise,
// --- Resultado --- // --- Resultado ---
PaginaFaltantesAlterada(usize), PaginaFaltantesAlterada(ChaveSerie, usize),
PaginaDuplicatasAlterada(usize), PaginaDuplicatasAlterada(ChaveSerie, usize),
ItensPorPaginaAlterado(usize), ItensPorPaginaAlterado(usize),
CopiarFaltantes(ChaveSerie), CopiarFaltantes(ChaveSerie),
CopiarDuplicatas(ChaveSerie), CopiarDuplicatas(ChaveSerie),
@@ -103,6 +102,9 @@ pub enum Message {
// --- Sem operação (used as fallback) --- // --- Sem operação (used as fallback) ---
Noop, Noop,
// --- Animação ---
SpinnerTick,
} }
/// Resultado enviado pela task de análise em background para a UI. /// Resultado enviado pela task de análise em background para a UI.
@@ -117,7 +119,7 @@ pub enum ResultadoPendente {
/// Pré-análise concluída mas precisa de confirmação do usuário. /// Pré-análise concluída mas precisa de confirmação do usuário.
AguardandoConfirmacao { AguardandoConfirmacao {
pre: ResultadoPreAnalise, pre: ResultadoPreAnalise,
series_excessivas: Vec<(ChaveSerie, u64)>, series_excessivas: Vec<(ChaveSerie, IntervaloSerie)>,
avisos: ResumoAvisos, avisos: ResumoAvisos,
notas: Vec<Nota>, notas: Vec<Nota>,
}, },
+69 -13
View File
@@ -82,20 +82,43 @@ pub fn view<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<'a, Me
let secao_duplicatas = view_duplicatas(app, resultado); let secao_duplicatas = view_duplicatas(app, resultado);
let secao_totais = view_totais(resultado); let secao_totais = view_totais(resultado);
let conteudo = column![ let mut col_principal = column![
row![text("Resultado da Análise").size(22).color(t::TEXT),], row![text("Resultado da Análise").size(22).color(t::TEXT),],
botoes_topo, botoes_topo,
stat_row, stat_row,
controle_pagina, controle_pagina,
secao_faltantes,
secao_duplicatas,
secao_totais,
] ]
.spacing(16) .spacing(16)
.padding(20) .padding(20)
.width(Length::Fill); .width(Length::Fill);
container(scrollable(conteudo)) // Badge "Sem inconsistências" quando não há faltantes nem duplicatas
if resultado.sem_inconsistencias() {
col_principal = col_principal.push(
container(
row![
container(text("").size(13).color(t::SUCCESS))
.padding([2, 8])
.style(t::badge_sucesso),
text("Sem inconsistências — todas as séries estão completas.")
.size(14)
.color(t::SUCCESS),
]
.spacing(8)
.align_y(Alignment::Center),
)
.padding([10, 16])
.width(Length::Fill)
.style(t::card),
);
}
col_principal = col_principal
.push(secao_faltantes)
.push(secao_duplicatas)
.push(secao_totais);
container(scrollable(col_principal))
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
.style(t::fundo) .style(t::fundo)
@@ -132,7 +155,16 @@ fn view_faltantes<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<
let mut col = column![titulo_row].spacing(12); let mut col = column![titulo_row].spacing(12);
if total == 0 { if total == 0 {
col = col.push(text("Nenhuma nota faltante.").size(14).color(t::SUCCESS)); col = col.push(
row![
container(text(" OK ").size(12).color(t::SUCCESS))
.padding([2, 8])
.style(t::badge_sucesso),
text("Nenhuma nota faltante.").size(14).color(t::SUCCESS),
]
.spacing(8)
.align_y(Alignment::Center),
);
return container(col) return container(col)
.padding(16) .padding(16)
.width(Length::Fill) .width(Length::Fill)
@@ -145,11 +177,25 @@ fn view_faltantes<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<
for chave in chaves { for chave in chaves {
let faltantes = &resultado.faltantes_por_serie[chave]; let faltantes = &resultado.faltantes_por_serie[chave];
let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0);
if faltantes.is_empty() { if faltantes.is_empty() {
// Série completa — exibir badge verde
let linha_serie = row![
container(text(" OK ").size(12).color(t::SUCCESS))
.padding([2, 8])
.style(t::badge_sucesso),
text(format!("Série {} — completa", chave.label()))
.size(14)
.color(t::SUCCESS),
]
.spacing(8)
.align_y(Alignment::Center);
col = col.push(linha_serie);
continue; continue;
} }
let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0);
let total_esperado = total_notas + faltantes.len(); let total_esperado = total_notas + faltantes.len();
let percentual = total_notas as f64 / total_esperado as f64; let percentual = total_notas as f64 / total_esperado as f64;
let percentual_f32 = percentual as f32; let percentual_f32 = percentual as f32;
@@ -189,7 +235,12 @@ fn view_faltantes<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<
// Paginação e lista // Paginação e lista
let total_paginas = (faltantes.len() + app.itens_por_pagina - 1) / app.itens_por_pagina; let total_paginas = (faltantes.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
let pagina = app.pagina_faltantes.min(total_paginas.saturating_sub(1)); let pagina = app
.pagina_faltantes
.get(chave)
.copied()
.unwrap_or(0)
.min(total_paginas.saturating_sub(1));
let inicio = pagina * app.itens_por_pagina; let inicio = pagina * app.itens_por_pagina;
let fim = (inicio + app.itens_por_pagina).min(faltantes.len()); let fim = (inicio + app.itens_por_pagina).min(faltantes.len());
@@ -210,8 +261,8 @@ fn view_faltantes<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<
col = col.push(crate::ui::components::paginacao::controles_paginacao( col = col.push(crate::ui::components::paginacao::controles_paginacao(
pagina, pagina,
total_paginas, total_paginas,
Message::PaginaFaltantesAlterada(pagina.saturating_sub(1)), Message::PaginaFaltantesAlterada(chave.clone(), pagina.saturating_sub(1)),
Message::PaginaFaltantesAlterada(pagina + 1), Message::PaginaFaltantesAlterada(chave.clone(), pagina + 1),
)); ));
} }
@@ -281,7 +332,12 @@ fn view_duplicatas<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element
col = col.push(cabecalho_serie); col = col.push(cabecalho_serie);
let total_paginas = (duplicatas.len() + app.itens_por_pagina - 1) / app.itens_por_pagina; let total_paginas = (duplicatas.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
let pagina = app.pagina_duplicatas.min(total_paginas.saturating_sub(1)); let pagina = app
.pagina_duplicatas
.get(chave)
.copied()
.unwrap_or(0)
.min(total_paginas.saturating_sub(1));
let inicio = pagina * app.itens_por_pagina; let inicio = pagina * app.itens_por_pagina;
let fim = (inicio + app.itens_por_pagina).min(duplicatas.len()); let fim = (inicio + app.itens_por_pagina).min(duplicatas.len());
@@ -304,8 +360,8 @@ fn view_duplicatas<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element
col = col.push(crate::ui::components::paginacao::controles_paginacao( col = col.push(crate::ui::components::paginacao::controles_paginacao(
pagina, pagina,
total_paginas, total_paginas,
Message::PaginaDuplicatasAlterada(pagina.saturating_sub(1)), Message::PaginaDuplicatasAlterada(chave.clone(), pagina.saturating_sub(1)),
Message::PaginaDuplicatasAlterada(pagina + 1), Message::PaginaDuplicatasAlterada(chave.clone(), pagina + 1),
)); ));
} }