Compare commits
2
Commits
5745a78a5a
...
06eca335db
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06eca335db | ||
|
|
6a5d941a00 |
+8
-12
@@ -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:** 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.
|
||||
|
||||
**Solução:** Verificar `fs::metadata(caminho)?.len()` antes de iniciar importação. Exibir modal de erro se exceder o limite.
|
||||
|
||||
**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)
|
||||
**Esforço estimado:** Baixo (30min) — *concluído*
|
||||
**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
|
||||
|
||||
| ID | Feature | Esforço | Impacto | Prioridade |
|
||||
|---|---|---|---|---|
|
||||
| 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 |
|
||||
@@ -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-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-09 | Agrupamento de faltantes no PDF | Baixo | Baixo-Médio | Baixa |
|
||||
| F-09 | Agrupamento de faltantes no PDF | Baixo | Baixo-Médio | Baixa | ✅ Implementado |
|
||||
|
||||
@@ -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
@@ -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.*
|
||||
@@ -2,7 +2,7 @@ use crate::domain::{
|
||||
entities::{
|
||||
chave_serie::ChaveSerie,
|
||||
nota::Nota,
|
||||
resultado_analise::{ResultadoAnalise, ResultadoPreAnalise},
|
||||
resultado_analise::{IntervaloSerie, ResultadoAnalise, ResultadoPreAnalise},
|
||||
},
|
||||
services::{
|
||||
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.
|
||||
/// Retorna lista de chaves que precisam de confirmação.
|
||||
pub fn series_com_intervalo_excessivo(pre: &ResultadoPreAnalise) -> Vec<(ChaveSerie, u64)> {
|
||||
/// Retorna lista de chaves com o intervalo completo para exibição ao usuário.
|
||||
pub fn series_com_intervalo_excessivo(
|
||||
pre: &ResultadoPreAnalise,
|
||||
) -> Vec<(ChaveSerie, IntervaloSerie)> {
|
||||
pre.intervalos_por_serie
|
||||
.iter()
|
||||
.filter(|(_, iv)| iv.excede_limite(LIMITE_FALTANTES))
|
||||
.map(|(chave, iv)| (chave.clone(), iv.contagem_faltantes))
|
||||
.map(|(chave, iv)| (chave.clone(), iv.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::domain::entities::nota::Nota;
|
||||
use crate::domain::entities::resultado_analise::ResultadoAnalise;
|
||||
use crate::infrastructure::pdf_generator::{MetadadosRelatorio, PdfGenerator};
|
||||
use chrono::Local;
|
||||
@@ -9,6 +10,7 @@ use std::path::Path;
|
||||
pub fn exportar_pdf(
|
||||
gerador: &dyn PdfGenerator,
|
||||
resultado: &ResultadoAnalise,
|
||||
notas: &[Nota],
|
||||
nome_arquivo: &str,
|
||||
nome_layout: Option<&str>,
|
||||
caminho_saida: &Path,
|
||||
@@ -19,5 +21,5 @@ pub fn exportar_pdf(
|
||||
gerado_em: Local::now(),
|
||||
};
|
||||
|
||||
gerador.gerar(resultado, &meta, caminho_saida)
|
||||
gerador.gerar(resultado, notas, &meta, caminho_saida)
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ pub fn importar_csv(
|
||||
config.indice_valor,
|
||||
config.indice_data,
|
||||
&mut avisos,
|
||||
);
|
||||
)?;
|
||||
|
||||
Ok(ResultadoImportacao { notas, avisos })
|
||||
}
|
||||
@@ -102,13 +102,16 @@ pub fn importar_xlsx(
|
||||
col_valor,
|
||||
col_data,
|
||||
&mut avisos,
|
||||
);
|
||||
)?;
|
||||
|
||||
Ok(ResultadoImportacao { notas, avisos })
|
||||
}
|
||||
|
||||
/// Converte linhas brutas (Vec<Vec<String>>) em notas fiscais,
|
||||
/// 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(
|
||||
linhas: &[Vec<String>],
|
||||
idx_numero: usize,
|
||||
@@ -117,9 +120,38 @@ fn mapear_linhas_para_notas(
|
||||
idx_valor: Option<usize>,
|
||||
idx_data: Option<usize>,
|
||||
avisos: &mut ResumoAvisos,
|
||||
) -> Vec<Nota> {
|
||||
) -> Result<Vec<Nota>, ErroArquivo> {
|
||||
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() {
|
||||
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
|
||||
Ok(notas)
|
||||
}
|
||||
|
||||
/// Faz o parsing de um número, tentando extrair dígitos se necessário.
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
use crate::domain::entities::chave_serie::ChaveSerie;
|
||||
use crate::domain::entities::nota::Nota;
|
||||
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 chrono::{DateTime, Local};
|
||||
use genpdf::{
|
||||
elements::{Break, Paragraph},
|
||||
fonts, style, Document, SimplePageDecorator,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
// Fontes embutidas no binário em tempo de compilação.
|
||||
@@ -28,6 +31,7 @@ pub trait PdfGenerator {
|
||||
fn gerar(
|
||||
&self,
|
||||
resultado: &ResultadoAnalise,
|
||||
notas: &[Nota],
|
||||
meta: &MetadadosRelatorio,
|
||||
caminho_saida: &Path,
|
||||
) -> Result<(), String>;
|
||||
@@ -40,9 +44,20 @@ impl PdfGenerator for GenpdfGenerator {
|
||||
fn gerar(
|
||||
&self,
|
||||
resultado: &ResultadoAnalise,
|
||||
notas: &[Nota],
|
||||
meta: &MetadadosRelatorio,
|
||||
caminho_saida: &Path,
|
||||
) -> 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 mut doc = Document::new(font_family);
|
||||
@@ -125,8 +140,19 @@ impl PdfGenerator for GenpdfGenerator {
|
||||
chave.label(),
|
||||
faltantes.len()
|
||||
)));
|
||||
let numeros: Vec<String> = faltantes.iter().map(|n| n.to_string()).collect();
|
||||
doc.push(Paragraph::new(format!(" {}", numeros.join(", "))));
|
||||
// Agrupar faltantes contíguos em intervalos (ex: 100–104 em vez de 100, 101, 102, 103, 104)
|
||||
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));
|
||||
@@ -158,11 +184,16 @@ impl PdfGenerator for GenpdfGenerator {
|
||||
duplicatas.len()
|
||||
)));
|
||||
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!(
|
||||
" NF {} / Série {} — {} ocorrências",
|
||||
" NF {} / Série {} — {} ocorrências{}",
|
||||
numero,
|
||||
chave.label(),
|
||||
count
|
||||
count,
|
||||
data_str,
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,8 +44,6 @@ pub fn aplicar_migrations(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Migration v2: adicionar índice único em layouts.nome.
|
||||
|
||||
/// Migration v3: adicionar colunas de tipo de documento nos layouts.
|
||||
fn migration_v3(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
@@ -55,6 +53,8 @@ fn migration_v3(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Migration v2: adicionar índice único em layouts.nome.
|
||||
///
|
||||
/// Renomeia duplicatas (sufixo com id) antes de criar o índice para não falhar
|
||||
/// em bancos que já possuem nomes repetidos.
|
||||
fn migration_v2(conn: &Connection) -> Result<()> {
|
||||
|
||||
@@ -62,29 +62,7 @@ pub fn ler_xlsx(
|
||||
continue;
|
||||
}
|
||||
|
||||
let campos: Vec<String> = row
|
||||
.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();
|
||||
let campos: Vec<String> = row.iter().map(celula_para_string).collect();
|
||||
|
||||
// Ignorar linhas completamente em branco
|
||||
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()
|
||||
.take(5)
|
||||
.map(|row| {
|
||||
row.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()
|
||||
row.iter().map(celula_para_string).collect()
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -178,6 +135,30 @@ pub fn parsear_letra_linha(s: &str) -> Option<Coordenada> {
|
||||
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> {
|
||||
let metadata =
|
||||
std::fs::metadata(caminho).map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
|
||||
|
||||
+144
-40
@@ -9,7 +9,7 @@ use crate::domain::{
|
||||
nota::Nota,
|
||||
resultado_analise::{ResultadoAnalise, ResultadoPreAnalise},
|
||||
},
|
||||
errors::{ErroLayout, ResumoAvisos},
|
||||
errors::{ErroArquivo, ErroLayout, ResumoAvisos},
|
||||
};
|
||||
use crate::infrastructure::sqlite::{connection::abrir_banco, migrations::aplicar_migrations};
|
||||
use crate::ui::message::{Message, ResultadoPendente};
|
||||
@@ -36,7 +36,6 @@ pub enum EstadoApp {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum EstadoModal {
|
||||
Informacao { titulo: String, mensagem: String },
|
||||
Aviso { titulo: String, mensagem: String },
|
||||
Erro { titulo: String, mensagem: String },
|
||||
Confirmacao { titulo: String, mensagem: String, acao: AcaoModal },
|
||||
@@ -82,9 +81,9 @@ pub struct App {
|
||||
// Avisos da última importação
|
||||
pub avisos_importacao: Option<ResumoAvisos>,
|
||||
|
||||
// Paginação
|
||||
pub pagina_faltantes: usize,
|
||||
pub pagina_duplicatas: usize,
|
||||
// Paginação (independente por série)
|
||||
pub pagina_faltantes: std::collections::HashMap<crate::domain::entities::chave_serie::ChaveSerie, usize>,
|
||||
pub pagina_duplicatas: std::collections::HashMap<crate::domain::entities::chave_serie::ChaveSerie, usize>,
|
||||
pub itens_por_pagina: usize,
|
||||
|
||||
// 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
|
||||
pub arquivo_em_hover: bool,
|
||||
|
||||
// Animação do spinner na tela de análise
|
||||
pub spinner_frame: usize,
|
||||
}
|
||||
|
||||
impl Default for App {
|
||||
@@ -114,12 +116,13 @@ impl Default for App {
|
||||
layouts_salvos: Vec::new(),
|
||||
modal: None,
|
||||
avisos_importacao: None,
|
||||
pagina_faltantes: 0,
|
||||
pagina_duplicatas: 0,
|
||||
pagina_faltantes: std::collections::HashMap::new(),
|
||||
pagina_duplicatas: std::collections::HashMap::new(),
|
||||
itens_por_pagina: 100,
|
||||
preview_arquivo: None,
|
||||
resultado_anterior: None,
|
||||
arquivo_em_hover: false,
|
||||
spinner_frame: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -210,7 +213,7 @@ impl App {
|
||||
},
|
||||
|result| match result {
|
||||
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()
|
||||
}
|
||||
|
||||
Message::CancelarExpansao => {
|
||||
self.estado = EstadoApp::ConfigurandoColunas;
|
||||
self.modal = None;
|
||||
Task::none()
|
||||
}
|
||||
|
||||
Message::NovaAnalise => {
|
||||
self.exibir_confirmacao(
|
||||
"Nova Análise",
|
||||
@@ -473,18 +470,18 @@ impl App {
|
||||
}
|
||||
|
||||
// ── Resultado ──
|
||||
Message::PaginaFaltantesAlterada(p) => {
|
||||
self.pagina_faltantes = p;
|
||||
Message::PaginaFaltantesAlterada(chave, p) => {
|
||||
self.pagina_faltantes.insert(chave, p);
|
||||
Task::none()
|
||||
}
|
||||
Message::PaginaDuplicatasAlterada(p) => {
|
||||
self.pagina_duplicatas = p;
|
||||
Message::PaginaDuplicatasAlterada(chave, p) => {
|
||||
self.pagina_duplicatas.insert(chave, p);
|
||||
Task::none()
|
||||
}
|
||||
Message::ItensPorPaginaAlterado(n) => {
|
||||
self.itens_por_pagina = n;
|
||||
self.pagina_faltantes = 0;
|
||||
self.pagina_duplicatas = 0;
|
||||
self.pagina_faltantes.clear();
|
||||
self.pagina_duplicatas.clear();
|
||||
Task::none()
|
||||
}
|
||||
Message::CopiarFaltantes(chave) => {
|
||||
@@ -659,6 +656,10 @@ impl App {
|
||||
}
|
||||
|
||||
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));
|
||||
Task::none()
|
||||
}
|
||||
@@ -679,6 +680,11 @@ impl App {
|
||||
|
||||
// ── Sem operação ──
|
||||
Message::Noop => Task::none(),
|
||||
|
||||
Message::SpinnerTick => {
|
||||
self.spinner_frame = self.spinner_frame.wrapping_add(1);
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -698,9 +704,11 @@ impl App {
|
||||
}
|
||||
EstadoApp::GerenciandoLayouts => crate::ui::screens::layouts::view(self),
|
||||
EstadoApp::Analisando => {
|
||||
const FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
let frame = FRAMES[self.spinner_frame % FRAMES.len()];
|
||||
container(
|
||||
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),
|
||||
]
|
||||
.spacing(8)
|
||||
@@ -742,7 +750,7 @@ impl App {
|
||||
// ─── Subscription (eventos de janela) ───────────────────────────────────
|
||||
|
||||
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)) => {
|
||||
Some(Message::ArquivoSolto(path))
|
||||
}
|
||||
@@ -753,7 +761,15 @@ impl App {
|
||||
Some(Message::ArquivoHoverSaiu)
|
||||
}
|
||||
_ => 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 ──────────────────────────────────────────────────────────
|
||||
@@ -862,6 +878,12 @@ impl App {
|
||||
let layout_csv = self.layout_csv_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;
|
||||
|
||||
Task::perform(
|
||||
@@ -929,11 +951,13 @@ impl App {
|
||||
self.avisos_importacao = if avisos.tem_avisos() { Some(avisos) } else { None };
|
||||
let msg = series_excessivas
|
||||
.iter()
|
||||
.map(|(chave, count)| {
|
||||
.map(|(chave, iv)| {
|
||||
format!(
|
||||
"Série {}: intervalo de {} faltantes detectado",
|
||||
"Série {}: intervalo de {} faltantes detectado (de {} a {})",
|
||||
chave.label(),
|
||||
count
|
||||
iv.contagem_faltantes,
|
||||
iv.minimo,
|
||||
iv.maximo,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
@@ -1072,6 +1096,7 @@ impl App {
|
||||
} else {
|
||||
Some(self.nome_layout_atual.clone())
|
||||
};
|
||||
let notas = self.notas_importadas.clone();
|
||||
|
||||
Task::perform(
|
||||
async move {
|
||||
@@ -1094,6 +1119,7 @@ impl App {
|
||||
exportar_pdf(
|
||||
&gerador,
|
||||
&resultado,
|
||||
¬as,
|
||||
&nome_arquivo,
|
||||
nome_layout.as_deref(),
|
||||
&caminho,
|
||||
@@ -1122,10 +1148,10 @@ impl App {
|
||||
let (conteudo, nome_sugerido) = match exportar_layout_json(&layout) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
// Não temos &mut self aqui; retornamos um command que exibe erro
|
||||
return Task::perform(async move { e.to_string() }, |e| {
|
||||
Message::PdfExportado(Err(e))
|
||||
});
|
||||
// Não temos &mut self aqui — reutilizamos PdfExportado(Err) para
|
||||
// exibir o modal de erro (o handler chama self.exibir_erro).
|
||||
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 {
|
||||
Ok(_) => 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 ─────
|
||||
|
||||
/// 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(
|
||||
caminho: PathBuf,
|
||||
tipo: TipoArquivo,
|
||||
@@ -1228,8 +1320,20 @@ fn executar_importacao_sync(
|
||||
layout_xlsx: LayoutXlsx,
|
||||
) -> ResultadoPendente {
|
||||
let res_importacao = match tipo {
|
||||
TipoArquivo::Csv => importar_csv(&caminho, &layout_csv).map_err(|e| e.to_string()),
|
||||
TipoArquivo::Xlsx => importar_xlsx(&caminho, &layout_xlsx).map_err(|e| e.to_string()),
|
||||
TipoArquivo::Csv => importar_csv(&caminho, &layout_csv).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(),
|
||||
}),
|
||||
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 {
|
||||
|
||||
@@ -28,9 +28,6 @@ pub fn view_com_modal<'a>(
|
||||
|
||||
fn view_modal(modal: &EstadoModal) -> Element<'_, Message> {
|
||||
match modal {
|
||||
EstadoModal::Informacao { titulo, mensagem } => {
|
||||
caixa_modal(titulo, mensagem, TipoModal::Info, None, false)
|
||||
}
|
||||
EstadoModal::Aviso { titulo, mensagem } => {
|
||||
caixa_modal(titulo, mensagem, TipoModal::Aviso, None, false)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ pub fn tabela_preview(linhas: &[Vec<String>]) -> Element<'_, Message> {
|
||||
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(
|
||||
row((0..num_colunas)
|
||||
.map(|i| {
|
||||
@@ -30,15 +30,7 @@ pub fn tabela_preview(linhas: &[Vec<String>]) -> Element<'_, Message> {
|
||||
.spacing(0),
|
||||
)
|
||||
.width(Length::Shrink)
|
||||
.style(|_theme| iced::widget::container::Style {
|
||||
background: Some(t::SURFACE_2.into()),
|
||||
border: Border {
|
||||
color: t::BORDER,
|
||||
width: 0.0,
|
||||
radius: 0.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
.style(t::cabecalho_tabela);
|
||||
|
||||
let linhas_view = linhas.iter().enumerate().map(|(idx, linha)| {
|
||||
// Linhas alternadas: SURFACE e SURFACE_2 levemente
|
||||
|
||||
+7
-5
@@ -2,7 +2,7 @@ use crate::domain::entities::nota::Nota;
|
||||
use crate::domain::entities::{
|
||||
chave_serie::ChaveSerie,
|
||||
layout::{Layout, LayoutXlsx},
|
||||
resultado_analise::{ResultadoAnalise, ResultadoPreAnalise},
|
||||
resultado_analise::{IntervaloSerie, ResultadoAnalise, ResultadoPreAnalise},
|
||||
};
|
||||
use crate::domain::errors::ResumoAvisos;
|
||||
use rusqlite::Connection;
|
||||
@@ -73,12 +73,11 @@ pub enum Message {
|
||||
ExecutarImportacao,
|
||||
ReanalisarArquivo,
|
||||
ConfirmarExpansaoFaltantes,
|
||||
CancelarExpansao,
|
||||
NovaAnalise,
|
||||
|
||||
// --- Resultado ---
|
||||
PaginaFaltantesAlterada(usize),
|
||||
PaginaDuplicatasAlterada(usize),
|
||||
PaginaFaltantesAlterada(ChaveSerie, usize),
|
||||
PaginaDuplicatasAlterada(ChaveSerie, usize),
|
||||
ItensPorPaginaAlterado(usize),
|
||||
CopiarFaltantes(ChaveSerie),
|
||||
CopiarDuplicatas(ChaveSerie),
|
||||
@@ -103,6 +102,9 @@ pub enum Message {
|
||||
|
||||
// --- Sem operação (used as fallback) ---
|
||||
Noop,
|
||||
|
||||
// --- Animação ---
|
||||
SpinnerTick,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
AguardandoConfirmacao {
|
||||
pre: ResultadoPreAnalise,
|
||||
series_excessivas: Vec<(ChaveSerie, u64)>,
|
||||
series_excessivas: Vec<(ChaveSerie, IntervaloSerie)>,
|
||||
avisos: ResumoAvisos,
|
||||
notas: Vec<Nota>,
|
||||
},
|
||||
|
||||
+69
-13
@@ -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_totais = view_totais(resultado);
|
||||
|
||||
let conteudo = column![
|
||||
let mut col_principal = column![
|
||||
row![text("Resultado da Análise").size(22).color(t::TEXT),],
|
||||
botoes_topo,
|
||||
stat_row,
|
||||
controle_pagina,
|
||||
secao_faltantes,
|
||||
secao_duplicatas,
|
||||
secao_totais,
|
||||
]
|
||||
.spacing(16)
|
||||
.padding(20)
|
||||
.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)
|
||||
.height(Length::Fill)
|
||||
.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);
|
||||
|
||||
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)
|
||||
.padding(16)
|
||||
.width(Length::Fill)
|
||||
@@ -145,11 +177,25 @@ fn view_faltantes<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<
|
||||
|
||||
for chave in chaves {
|
||||
let faltantes = &resultado.faltantes_por_serie[chave];
|
||||
|
||||
let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0);
|
||||
let total_esperado = total_notas + faltantes.len();
|
||||
let percentual = total_notas as f64 / total_esperado as f64;
|
||||
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
|
||||
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 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(
|
||||
pagina,
|
||||
total_paginas,
|
||||
Message::PaginaFaltantesAlterada(pagina.saturating_sub(1)),
|
||||
Message::PaginaFaltantesAlterada(pagina + 1),
|
||||
Message::PaginaFaltantesAlterada(chave.clone(), pagina.saturating_sub(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);
|
||||
|
||||
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 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(
|
||||
pagina,
|
||||
total_paginas,
|
||||
Message::PaginaDuplicatasAlterada(pagina.saturating_sub(1)),
|
||||
Message::PaginaDuplicatasAlterada(pagina + 1),
|
||||
Message::PaginaDuplicatasAlterada(chave.clone(), pagina.saturating_sub(1)),
|
||||
Message::PaginaDuplicatasAlterada(chave.clone(), pagina + 1),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user