Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5605de6a3 | ||
|
|
0354ece935 |
@@ -0,0 +1,184 @@
|
||||
# Análise — Duplicidade de Layouts ao Salvar
|
||||
|
||||
**Data:** 03/03/2026
|
||||
**Status:** Análise / Proposta de Correção
|
||||
|
||||
---
|
||||
|
||||
## 1. Problema Identificado
|
||||
|
||||
Ao clicar em **"💾 Salvar"** na tela de gerenciamento de layouts, o sistema permite salvar um layout com um nome já existente, criando entradas duplicadas no banco sem qualquer aviso ou confirmação ao usuário.
|
||||
|
||||
O PRD (RF03) exige que, ao existir conflito de nome, o sistema pergunte ao usuário se deseja **sobrescrever** o existente ou **salvar com novo nome**. Esse comportamento está **implementado apenas no fluxo de importação JSON**, mas está **ausente no fluxo de salvar layout atual**.
|
||||
|
||||
---
|
||||
|
||||
## 2. Raiz do Problema — Análise por Camada
|
||||
|
||||
### 2.1 Camada Infrastructure — `sqlite/migrations.rs`
|
||||
|
||||
A tabela `layouts` foi criada na `migration_v1` **sem nenhuma constraint `UNIQUE` no campo `nome`**:
|
||||
|
||||
```sql
|
||||
-- migration_v1 (src/infrastructure/sqlite/migrations.rs)
|
||||
CREATE TABLE IF NOT EXISTS layouts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
nome TEXT NOT NULL, -- ← sem UNIQUE
|
||||
tipo TEXT NOT NULL CHECK(tipo IN ('csv', 'xlsx')),
|
||||
...
|
||||
);
|
||||
```
|
||||
|
||||
O banco de dados não impede, por si só, a inserção de registros com o mesmo nome.
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Camada Infrastructure — `sqlite/layout_repository.rs`
|
||||
|
||||
A função `salvar` executa um `INSERT` direto, sem qualquer verificação prévia:
|
||||
|
||||
```rust
|
||||
// src/infrastructure/sqlite/layout_repository.rs — fn salvar()
|
||||
conn.execute(
|
||||
"INSERT INTO layouts (nome, tipo, ...) VALUES (?1, 'csv', ...)",
|
||||
params![nome, ...],
|
||||
)?;
|
||||
```
|
||||
|
||||
Existe a função `existe_nome` no mesmo arquivo, que verifica duplicidade por consulta, mas ela **não é chamada** pelo fluxo de salvar layout atual — apenas pelo fluxo de importação JSON.
|
||||
|
||||
---
|
||||
|
||||
### 2.3 Camada Application — `usecases/layouts.rs`
|
||||
|
||||
A função `salvar_layout` só verifica se o `id` está presente:
|
||||
|
||||
```rust
|
||||
// src/application/usecases/layouts.rs — fn salvar_layout()
|
||||
if let Some(id) = layout.id() {
|
||||
layout_repository::atualizar(conn, layout) // atualiza se tem id
|
||||
} else {
|
||||
layout_repository::salvar(conn, layout) // insere direto sem checar nome
|
||||
}
|
||||
```
|
||||
|
||||
A verificação de nome duplicado (`existe_nome`) **está implementada em `importar_layout_json`**, mas **não foi replicada** em `salvar_layout`.
|
||||
|
||||
---
|
||||
|
||||
### 2.4 Camada UI — `ui/screens/layouts.rs`
|
||||
|
||||
A função `salvar_layout_atual` monta o layout sempre com `id: None` e chama `salvar_layout` sem tratar o caso de conflito de nome:
|
||||
|
||||
```rust
|
||||
// src/ui/screens/layouts.rs — fn salvar_layout_atual()
|
||||
let layout = match app.tipo_arquivo_atual.clone() {
|
||||
TipoArquivo::Csv => Layout::Csv {
|
||||
id: None, // ← sempre None, nunca verifica se nome já existe
|
||||
nome: app.nome_layout_atual.trim().to_string(),
|
||||
config: app.layout_csv_atual.clone(),
|
||||
},
|
||||
...
|
||||
};
|
||||
|
||||
match salvar_layout(conn, &layout) {
|
||||
Ok(_) => { ... } // ← não há tratamento para conflito de nome
|
||||
Err(e) => { ... }
|
||||
}
|
||||
```
|
||||
|
||||
O fluxo de resolução de conflito (sobrescrever / salvar com novo nome) **existe apenas em `importar_json`**, que trata `ErroLayout::NomeConflitante`, mas mesmo nesse caso o tratamento está incompleto (há um `TODO` no código).
|
||||
|
||||
---
|
||||
|
||||
## 3. Resumo dos Pontos Falhos
|
||||
|
||||
| # | Localização | Problema |
|
||||
|---|-------------|----------|
|
||||
| 1 | `sqlite/migrations.rs` — `migration_v1` | Nenhum `UNIQUE` constraint em `nome` na tabela `layouts` |
|
||||
| 2 | `usecases/layouts.rs` — `salvar_layout` | Não consulta `existe_nome` antes de inserir |
|
||||
| 3 | `domain/errors.rs` | Falta variante de erro para duplicidade no fluxo de salvar (apenas existe em importar JSON) |
|
||||
| 4 | `ui/screens/layouts.rs` — `salvar_layout_atual` | Não trata conflito de nome; não exibe opção de sobrescrever ou renomear |
|
||||
| 5 | `ui/screens/layouts.rs` — `importar_json` | Tratamento de `NomeConflitante` incompleto (TODO pendente) |
|
||||
|
||||
---
|
||||
|
||||
## 4. Proposta de Correção
|
||||
|
||||
### 4.1 Migration v2 — Adicionar `UNIQUE` em `nome`
|
||||
|
||||
**Arquivo:** `src/infrastructure/sqlite/migrations.rs`
|
||||
|
||||
Adicionar uma segunda migration que cria uma `UNIQUE` constraint na coluna `nome`.
|
||||
Atualizar `VERSAO_SCHEMA_ATUAL` de `1` para `2` e chamar `migration_v2` no bloco condicional de aplicação de migrations.
|
||||
|
||||
```
|
||||
-- migration_v2
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_layouts_nome ON layouts (nome);
|
||||
```
|
||||
|
||||
> Usar um índice único em vez de recriar a tabela é mais seguro, pois preserva dados já existentes. Bancos que já possuem duplicatas precisariam de tratamento antes da migração (ex.: renomear automaticamente os conflitantes com sufixo numérico).
|
||||
|
||||
---
|
||||
|
||||
### 4.2 Retornar Erro Tipado de Conflito em `salvar_layout`
|
||||
|
||||
**Arquivo:** `src/application/usecases/layouts.rs` — função `salvar_layout`
|
||||
|
||||
Antes da inserção, verificar se já existe layout com o mesmo nome usando `layout_repository::existe_nome`. Retornar um erro tipado (ex.: `ErroLayout::NomeConflitante`) em vez de uma `String` quando o nome já existir e nenhum `id` foi fornecido.
|
||||
|
||||
Fluxo esperado:
|
||||
|
||||
```
|
||||
1. layout.id() é None (novo layout)
|
||||
2. Consultar existe_nome(conn, layout.nome())
|
||||
3. Se existir → retornar Err(ErroLayout::NomeConflitante(nome))
|
||||
4. Se não existir → prosseguir com INSERT
|
||||
```
|
||||
|
||||
A assinatura da função deverá mudar de `Result<i64, String>` para `Result<i64, ErroLayout>` para que o chamador consiga distinguir o tipo de erro.
|
||||
|
||||
---
|
||||
|
||||
### 4.3 Tratar Conflito na UI — Fluxo de Sobrescrever ou Renomear
|
||||
|
||||
**Arquivo:** `src/ui/screens/layouts.rs` — função `salvar_layout_atual`
|
||||
|
||||
Ao receber `Err(ErroLayout::NomeConflitante(nome))`, a UI deve exibir um modal de confirmação com duas opções:
|
||||
|
||||
- **Sobrescrever** — buscar o `id` do layout existente pelo nome, montar o layout com esse `id` e chamar `salvar_layout` novamente (o use case irá para o branch `atualizar`)
|
||||
- **Cancelar** — descartar a operação sem salvar
|
||||
|
||||
> A opção "Salvar com novo nome" pode ser considerada, porém aumenta a complexidade da tela. Para a correção mínima, sobrescrever ou cancelar já atende ao PRD.
|
||||
|
||||
O estado da `App` pode precisar de um campo intermediário para guardar o layout pendente de confirmação entre frames do egui, ou se o sistema já possui um mecanismo de `AcaoModal`, criar uma variante `AcaoModal::ConfirmarSobrescritaLayout(Layout)` e processar no handler de ações do modal.
|
||||
|
||||
---
|
||||
|
||||
### 4.4 Completar o `TODO` em `importar_json`
|
||||
|
||||
**Arquivo:** `src/ui/screens/layouts.rs` — função `importar_json`
|
||||
|
||||
O bloco `Err(ErroLayout::NomeConflitante(nome))` contém um `TODO` que não implementa o fluxo completo. Após a correção do item 4.3, a mesma lógica de modal de confirmação deve ser reutilizada aqui, chamando `importar_layout_json` com `sobrescrever_se_existir: true` quando o usuário confirmar.
|
||||
|
||||
---
|
||||
|
||||
## 5. Ordem de Implementação Sugerida
|
||||
|
||||
```
|
||||
1. migration_v2 (constraint UNIQUE) ............ infrastructure/sqlite/migrations.rs
|
||||
2. Erro tipado em salvar_layout ................. application/usecases/layouts.rs
|
||||
3. Modal de conflito na UI (salvar atual) ....... ui/screens/layouts.rs
|
||||
4. Modal de conflito na UI (importar JSON) ...... ui/screens/layouts.rs (completar TODO)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Impacto da Correção
|
||||
|
||||
| Aspecto | Antes | Depois |
|
||||
|---------|-------|--------|
|
||||
| Layouts duplicados no banco | Possível | Bloqueado pelo banco (UNIQUE) |
|
||||
| Feedback ao usuário | Nenhum — salva silenciosamente | Modal com escolha: sobrescrever ou cancelar |
|
||||
| Conformidade com PRD (RF03) | Parcial (apenas importação JSON) | Completa em todos os fluxos |
|
||||
| Importação JSON com conflito | TODO incompleto | Resolvido com mesma lógica |
|
||||
@@ -1,11 +1,11 @@
|
||||
use crate::domain::{
|
||||
entities::{
|
||||
nota::Nota,
|
||||
resultado_analise::{IntervaloSerie, ResultadoAnalise, ResultadoPreAnalise},
|
||||
resultado_analise::{ResultadoAnalise, ResultadoPreAnalise},
|
||||
},
|
||||
services::{
|
||||
detector_duplicidade::duplicidades_por_serie,
|
||||
detector_sequencia::{calcular_intervalo, detectar_faltantes, LIMITE_FALTANTES},
|
||||
detector_sequencia::{LIMITE_FALTANTES, calcular_intervalo, detectar_faltantes},
|
||||
},
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
@@ -55,9 +55,7 @@ pub fn pre_analisar(notas: &[Nota]) -> ResultadoPreAnalise {
|
||||
|
||||
/// Verifica se alguma série excede o limite de faltantes.
|
||||
/// Retorna lista de séries que precisam de confirmação.
|
||||
pub fn series_com_intervalo_excessivo(
|
||||
pre: &ResultadoPreAnalise,
|
||||
) -> Vec<(String, u64)> {
|
||||
pub fn series_com_intervalo_excessivo(pre: &ResultadoPreAnalise) -> Vec<(String, u64)> {
|
||||
pre.intervalos_por_serie
|
||||
.iter()
|
||||
.filter(|(_, iv)| iv.excede_limite(LIMITE_FALTANTES))
|
||||
@@ -89,19 +87,18 @@ pub fn expandir_analise(pre: ResultadoPreAnalise, notas: &[Nota]) -> ResultadoAn
|
||||
}
|
||||
}
|
||||
|
||||
/// Executa análise completa sem verificar limites (use case simplificado).
|
||||
/// Útil quando o caller já confirmou ou sabe que não há intervalos excessivos.
|
||||
pub fn executar_analise(notas: &[Nota]) -> ResultadoAnalise {
|
||||
let pre = pre_analisar(notas);
|
||||
expandir_analise(pre, notas)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::entities::nota::Nota;
|
||||
use rust_decimal_macros::dec;
|
||||
|
||||
/// Executa análise completa sem verificar limites (atalho para testes).
|
||||
fn executar_analise(notas: &[Nota]) -> ResultadoAnalise {
|
||||
let pre = pre_analisar(notas);
|
||||
expandir_analise(pre, notas)
|
||||
}
|
||||
|
||||
fn nota(numero: u64, serie: &str, valor: Option<rust_decimal::Decimal>) -> Nota {
|
||||
Nota::new(numero, serie.to_string(), valor, None)
|
||||
}
|
||||
@@ -133,10 +130,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn pre_analise_detecta_intervalo_excessivo() {
|
||||
let notas = vec![
|
||||
nota(1, "001", None),
|
||||
nota(20_000, "001", None),
|
||||
];
|
||||
let notas = vec![nota(1, "001", None), nota(20_000, "001", None)];
|
||||
let pre = pre_analisar(¬as);
|
||||
let excessivos = series_com_intervalo_excessivo(&pre);
|
||||
assert_eq!(excessivos.len(), 1);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::domain::entities::layout::{Layout, LayoutCsv, LayoutXlsx};
|
||||
use crate::domain::entities::layout::{LayoutCsv, LayoutXlsx};
|
||||
use crate::domain::entities::nota::Nota;
|
||||
use crate::domain::entities::serie::validar_serie;
|
||||
use crate::domain::errors::{ErroArquivo, ResumoAvisos};
|
||||
@@ -24,7 +24,10 @@ pub fn listar_abas_xlsx(caminho: &Path) -> Result<InfoXlsx, ErroArquivo> {
|
||||
}
|
||||
|
||||
/// Importa um arquivo CSV e retorna as notas válidas + avisos consolidados.
|
||||
pub fn importar_csv(caminho: &Path, config: &LayoutCsv) -> Result<ResultadoImportacao, ErroArquivo> {
|
||||
pub fn importar_csv(
|
||||
caminho: &Path,
|
||||
config: &LayoutCsv,
|
||||
) -> Result<ResultadoImportacao, ErroArquivo> {
|
||||
let resultado = csv_reader::ler_csv(
|
||||
caminho,
|
||||
config.delimitador as u8,
|
||||
@@ -46,17 +49,21 @@ pub fn importar_csv(caminho: &Path, config: &LayoutCsv) -> Result<ResultadoImpor
|
||||
}
|
||||
|
||||
/// Importa um arquivo XLSX e retorna as notas válidas + avisos consolidados.
|
||||
pub fn importar_xlsx(caminho: &Path, config: &LayoutXlsx) -> Result<ResultadoImportacao, ErroArquivo> {
|
||||
pub fn importar_xlsx(
|
||||
caminho: &Path,
|
||||
config: &LayoutXlsx,
|
||||
) -> Result<ResultadoImportacao, ErroArquivo> {
|
||||
// Determinar linha de início a partir de qualquer campo mapeado
|
||||
let linha_inicio = {
|
||||
let coord_num = xlsx_reader::parsear_letra_linha(&config.pos_numero)
|
||||
.ok_or_else(|| ErroArquivo::ErroLeitura(format!(
|
||||
"Posição de Numero inválida: '{}'", config.pos_numero
|
||||
)))?;
|
||||
let coord_ser = xlsx_reader::parsear_letra_linha(&config.pos_serie)
|
||||
.ok_or_else(|| ErroArquivo::ErroLeitura(format!(
|
||||
"Posição de Serie inválida: '{}'", config.pos_serie
|
||||
)))?;
|
||||
let coord_num = xlsx_reader::parsear_letra_linha(&config.pos_numero).ok_or_else(|| {
|
||||
ErroArquivo::ErroLeitura(format!(
|
||||
"Posição de Numero inválida: '{}'",
|
||||
config.pos_numero
|
||||
))
|
||||
})?;
|
||||
let coord_ser = xlsx_reader::parsear_letra_linha(&config.pos_serie).ok_or_else(|| {
|
||||
ErroArquivo::ErroLeitura(format!("Posição de Serie inválida: '{}'", config.pos_serie))
|
||||
})?;
|
||||
coord_num.linha.min(coord_ser.linha)
|
||||
};
|
||||
|
||||
@@ -113,7 +120,9 @@ fn mapear_linhas_para_notas(
|
||||
Some(s) if !s.trim().is_empty() => s.trim().to_string(),
|
||||
_ => {
|
||||
avisos.numeros_invalidos += 1;
|
||||
avisos.detalhes.push(format!("Linha {}: campo Numero ausente", linha_num));
|
||||
avisos
|
||||
.detalhes
|
||||
.push(format!("Linha {}: campo Numero ausente", linha_num));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -123,7 +132,9 @@ fn mapear_linhas_para_notas(
|
||||
Ok(n) => n,
|
||||
Err(msg) => {
|
||||
avisos.numeros_invalidos += 1;
|
||||
avisos.detalhes.push(format!("Linha {}: {}", linha_num, msg));
|
||||
avisos
|
||||
.detalhes
|
||||
.push(format!("Linha {}: {}", linha_num, msg));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -133,7 +144,9 @@ fn mapear_linhas_para_notas(
|
||||
Some(s) => s.trim().to_string(),
|
||||
None => {
|
||||
avisos.series_invalidas += 1;
|
||||
avisos.detalhes.push(format!("Linha {}: campo Serie ausente", linha_num));
|
||||
avisos
|
||||
.detalhes
|
||||
.push(format!("Linha {}: campo Serie ausente", linha_num));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::domain::{
|
||||
entities::layout::{Layout, LayoutJson},
|
||||
errors::ErroLayout,
|
||||
};
|
||||
use crate::infrastructure::sqlite::{layout_repository, migrations};
|
||||
use crate::infrastructure::sqlite::layout_repository;
|
||||
use rusqlite::Connection;
|
||||
|
||||
/// Salva um layout no banco de dados.
|
||||
@@ -14,12 +14,10 @@ pub fn salvar_layout(conn: &Connection, layout: &Layout) -> Result<i64, String>
|
||||
}
|
||||
|
||||
if let Some(id) = layout.id() {
|
||||
layout_repository::atualizar(conn, layout)
|
||||
.map_err(|e| e.to_string())?;
|
||||
layout_repository::atualizar(conn, layout).map_err(|e| e.to_string())?;
|
||||
Ok(id)
|
||||
} else {
|
||||
layout_repository::salvar(conn, layout)
|
||||
.map_err(|e| e.to_string())
|
||||
layout_repository::salvar(conn, layout).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,11 +26,6 @@ pub fn listar_layouts(conn: &Connection) -> Result<Vec<Layout>, String> {
|
||||
layout_repository::listar(conn).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Carrega um layout pelo id.
|
||||
pub fn carregar_layout(conn: &Connection, id: i64) -> Result<Option<Layout>, String> {
|
||||
layout_repository::buscar_por_id(conn, id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Exclui um layout pelo id.
|
||||
pub fn excluir_layout(conn: &Connection, id: i64) -> Result<(), String> {
|
||||
layout_repository::excluir(conn, id).map_err(|e| e.to_string())
|
||||
@@ -61,8 +54,8 @@ pub fn importar_layout_json(
|
||||
novo_nome: Option<&str>,
|
||||
) -> Result<i64, ErroLayout> {
|
||||
// Deserializar
|
||||
let json_repr: LayoutJson = serde_json::from_str(json)
|
||||
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
|
||||
let json_repr: LayoutJson =
|
||||
serde_json::from_str(json).map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
|
||||
|
||||
let mut layout = Layout::try_from(json_repr)?;
|
||||
|
||||
@@ -105,6 +98,5 @@ pub fn importar_layout_json(
|
||||
}
|
||||
|
||||
// Inserir novo
|
||||
layout_repository::salvar(conn, &layout)
|
||||
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))
|
||||
layout_repository::salvar(conn, &layout).map_err(|e| ErroLayout::JsonMalformado(e.to_string()))
|
||||
}
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error, Clone)]
|
||||
pub enum ErroNumero {
|
||||
#[error("Número zero não é válido para uma nota fiscal")]
|
||||
Zero,
|
||||
#[error("Valor não numérico: '{0}'")]
|
||||
NaoNumerico(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, Clone)]
|
||||
pub enum ErroSerie {
|
||||
#[error("Série inválida: '{0}' (deve conter de 1 a 3 dígitos numéricos)")]
|
||||
@@ -40,52 +32,10 @@ pub enum ErroArquivo {
|
||||
TamanhoExcedido(u64),
|
||||
#[error("Arquivo corrompido ou ilegível: {0}")]
|
||||
Corrompido(String),
|
||||
#[error("Formato não suportado: {0}")]
|
||||
FormatoNaoSuportado(String),
|
||||
#[error("Erro de leitura: {0}")]
|
||||
ErroLeitura(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ErroAnalise {
|
||||
#[error("Intervalo de faltantes muito grande para a série '{serie}': {intervalo} registros")]
|
||||
IntervaloMuitoGrande { serie: String, intervalo: u64 },
|
||||
#[error("Campo obrigatório não mapeado: {0}")]
|
||||
CampoObrigatorioNaoMapeado(String),
|
||||
#[error("Índice de coluna inválido: campo '{campo}', índice {indice}")]
|
||||
IndiceInvalido { campo: String, indice: usize },
|
||||
#[error("Dois campos mapeados para o mesmo índice: {0}")]
|
||||
IndicesDuplicados(String),
|
||||
}
|
||||
|
||||
/// Aviso coletado durante a importação, para ser exibido de forma consolidada ao usuário.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AvisoImportacao {
|
||||
pub categoria: CategoriaAviso,
|
||||
pub mensagem: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CategoriaAviso {
|
||||
LinhaMalformada,
|
||||
NumeroInvalido,
|
||||
SerieInvalida,
|
||||
ValorInvalido,
|
||||
RegistroDescartado,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CategoriaAviso {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
CategoriaAviso::LinhaMalformada => write!(f, "Linhas malformadas"),
|
||||
CategoriaAviso::NumeroInvalido => write!(f, "Valores de Numero inválidos"),
|
||||
CategoriaAviso::SerieInvalida => write!(f, "Registros com Série inválida"),
|
||||
CategoriaAviso::ValorInvalido => write!(f, "Valores monetários inválidos"),
|
||||
CategoriaAviso::RegistroDescartado => write!(f, "Registros descartados"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resumo consolidado de avisos para exibição em um único modal.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct ResumoAvisos {
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::domain::services::parser_monetario::formatar_valor_br;
|
||||
use chrono::{DateTime, Local};
|
||||
use genpdf::{
|
||||
elements::{Break, Paragraph},
|
||||
fonts, style, Document, Element, SimplePageDecorator,
|
||||
fonts, style, Document, SimplePageDecorator,
|
||||
};
|
||||
use std::path::Path;
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
use rusqlite::Connection;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const LIMITE_50MB: u64 = 50 * 1024 * 1024;
|
||||
|
||||
/// Determina o caminho do banco de dados conforme o sistema operacional.
|
||||
pub fn caminho_banco() -> PathBuf {
|
||||
let config_dir = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
|
||||
@@ -33,7 +31,7 @@ pub fn abrir_banco_no_caminho(caminho: &Path) -> Result<(Connection, bool), Stri
|
||||
// Testar se o banco funciona com uma query simples
|
||||
match conn.execute_batch("SELECT 1;") {
|
||||
Ok(_) => return Ok((conn, false)),
|
||||
Err(e) => {
|
||||
Err(_e) => {
|
||||
// Banco corrompido
|
||||
drop(conn);
|
||||
let bak = caminho.with_extension("db.bak");
|
||||
|
||||
@@ -145,55 +145,6 @@ pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
|
||||
layouts
|
||||
}
|
||||
|
||||
/// Busca um layout pelo id.
|
||||
pub fn buscar_por_id(conn: &Connection, id: i64) -> Result<Option<Layout>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, nome, tipo,
|
||||
delimitador, encoding, linha_cabecalho,
|
||||
indice_numero, indice_serie, indice_valor, indice_data,
|
||||
aba, pos_numero, pos_serie, pos_valor, pos_data
|
||||
FROM layouts WHERE id = ?1",
|
||||
)?;
|
||||
|
||||
let mut results = stmt.query_map([id], |row| {
|
||||
let id: i64 = row.get(0)?;
|
||||
let nome: String = row.get(1)?;
|
||||
let tipo: String = row.get(2)?;
|
||||
|
||||
if tipo == "csv" {
|
||||
let delim_str: String = row.get(3)?;
|
||||
let delimitador = delim_str.chars().next().unwrap_or(';');
|
||||
Ok(Layout::Csv {
|
||||
id: Some(id),
|
||||
nome,
|
||||
config: LayoutCsv {
|
||||
delimitador,
|
||||
encoding: row.get(4)?,
|
||||
linha_cabecalho: row.get::<_, i64>(5)? as usize,
|
||||
indice_numero: row.get::<_, i64>(6)? as usize,
|
||||
indice_serie: row.get::<_, i64>(7)? as usize,
|
||||
indice_valor: row.get::<_, Option<i64>>(8)?.map(|v| v as usize),
|
||||
indice_data: row.get::<_, Option<i64>>(9)?.map(|v| v as usize),
|
||||
},
|
||||
})
|
||||
} else {
|
||||
Ok(Layout::Xlsx {
|
||||
id: Some(id),
|
||||
nome,
|
||||
config: LayoutXlsx {
|
||||
aba: row.get(10)?,
|
||||
pos_numero: row.get(11)?,
|
||||
pos_serie: row.get(12)?,
|
||||
pos_valor: row.get(13)?,
|
||||
pos_data: row.get(14)?,
|
||||
},
|
||||
})
|
||||
}
|
||||
})?;
|
||||
|
||||
results.next().transpose()
|
||||
}
|
||||
|
||||
/// Remove um layout pelo id.
|
||||
pub fn excluir(conn: &Connection, id: i64) -> Result<()> {
|
||||
conn.execute("DELETE FROM layouts WHERE id = ?1", [id])?;
|
||||
|
||||
@@ -21,12 +21,12 @@ pub fn aplicar_migrations(conn: &Connection) -> Result<()> {
|
||||
)
|
||||
.unwrap_or(0);
|
||||
|
||||
if versao_atual < 1 {
|
||||
if versao_atual < VERSAO_SCHEMA_ATUAL {
|
||||
migration_v1(conn)?;
|
||||
if versao_atual == 0 {
|
||||
conn.execute("INSERT INTO schema_version (versao) VALUES (?1);", [1])?;
|
||||
conn.execute("INSERT INTO schema_version (versao) VALUES (?1);", [VERSAO_SCHEMA_ATUAL])?;
|
||||
} else {
|
||||
conn.execute("UPDATE schema_version SET versao = ?1;", [1])?;
|
||||
conn.execute("UPDATE schema_version SET versao = ?1;", [VERSAO_SCHEMA_ATUAL])?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ pub fn ler_xlsx(
|
||||
.worksheet_range(nome_aba)
|
||||
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
|
||||
|
||||
let mut avisos = ResumoAvisos::default();
|
||||
let avisos = ResumoAvisos::default();
|
||||
let mut linhas: Vec<Vec<String>> = Vec::new();
|
||||
|
||||
let linha_inicio_base0 = (linha_inicio.saturating_sub(1)) as usize;
|
||||
|
||||
+32
-30
@@ -1,10 +1,6 @@
|
||||
use crate::application::usecases::{
|
||||
executar_analise::{expandir_analise, pre_analisar, series_com_intervalo_excessivo},
|
||||
importar_arquivo::{importar_csv, importar_xlsx, listar_abas_xlsx, ResultadoImportacao},
|
||||
layouts::{
|
||||
carregar_layout, excluir_layout, exportar_layout_json, importar_layout_json,
|
||||
listar_layouts, salvar_layout,
|
||||
},
|
||||
layouts::{excluir_layout, listar_layouts, salvar_layout},
|
||||
};
|
||||
use crate::domain::{
|
||||
entities::{
|
||||
@@ -14,10 +10,7 @@ use crate::domain::{
|
||||
},
|
||||
errors::ResumoAvisos,
|
||||
};
|
||||
use crate::infrastructure::{
|
||||
pdf_generator::GenpdfGenerator,
|
||||
sqlite::{connection::abrir_banco, migrations::aplicar_migrations},
|
||||
};
|
||||
use crate::infrastructure::sqlite::{connection::abrir_banco, migrations::aplicar_migrations};
|
||||
use egui::Context;
|
||||
use rusqlite::Connection;
|
||||
use std::path::PathBuf;
|
||||
@@ -34,10 +27,7 @@ pub enum EstadoApp {
|
||||
/// Resultado pronto para exibição.
|
||||
ExibindoResultado(ResultadoAnalise),
|
||||
/// Aguardando confirmação do usuário para expandir faltantes.
|
||||
ConfirmandoIntervalo {
|
||||
pre: ResultadoPreAnalise,
|
||||
series_excessivas: Vec<(String, u64)>,
|
||||
},
|
||||
ConfirmandoIntervalo { pre: ResultadoPreAnalise },
|
||||
/// Gerenciamento de layouts.
|
||||
GerenciandoLayouts,
|
||||
/// Análise em execução em background (thread separada).
|
||||
@@ -72,6 +62,7 @@ pub enum TipoModal {
|
||||
pub enum AcaoModal {
|
||||
ConfirmarExpansaoFaltantes,
|
||||
ConfirmarExclusaoLayout(i64),
|
||||
#[allow(dead_code)]
|
||||
SobrescreverLayout,
|
||||
ConfirmarNovaAnalise,
|
||||
/// Salvar a configuração atual como novo layout, usando modal.input_texto como nome.
|
||||
@@ -396,12 +387,20 @@ impl App {
|
||||
/// Processa o resultado recebido do canal de análise em background.
|
||||
pub fn processar_resultado_pendente(&mut self, resultado: ResultadoPendente) {
|
||||
match resultado {
|
||||
ResultadoPendente::Concluido { resultado, avisos, notas } => {
|
||||
ResultadoPendente::Concluido {
|
||||
resultado,
|
||||
avisos,
|
||||
notas,
|
||||
} => {
|
||||
if let Some(n) = notas {
|
||||
self.notas_importadas = n;
|
||||
}
|
||||
if let Some(av) = &avisos {
|
||||
self.avisos_importacao = if av.tem_avisos() { Some(av.clone()) } else { None };
|
||||
self.avisos_importacao = if av.tem_avisos() {
|
||||
Some(av.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
self.estado = EstadoApp::ExibindoResultado(resultado);
|
||||
if let Some(av) = &self.avisos_importacao.clone() {
|
||||
@@ -418,18 +417,22 @@ impl App {
|
||||
notas,
|
||||
} => {
|
||||
self.notas_importadas = notas;
|
||||
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
|
||||
.iter()
|
||||
.map(|(serie, count)| {
|
||||
format!("Série {}: intervalo de {} faltantes detectado", serie, count)
|
||||
format!(
|
||||
"Série {}: intervalo de {} faltantes detectado",
|
||||
serie, count
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
self.estado = EstadoApp::ConfirmandoIntervalo {
|
||||
pre,
|
||||
series_excessivas,
|
||||
};
|
||||
self.estado = EstadoApp::ConfirmandoIntervalo { pre };
|
||||
self.exibir_confirmacao(
|
||||
"Intervalo muito grande",
|
||||
format!(
|
||||
@@ -464,16 +467,16 @@ impl App {
|
||||
let msg = excessivos
|
||||
.iter()
|
||||
.map(|(serie, count)| {
|
||||
format!("Série {}: intervalo de {} faltantes detectado", serie, count)
|
||||
format!(
|
||||
"Série {}: intervalo de {} faltantes detectado",
|
||||
serie, count
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
let acao = AcaoModal::ConfirmarExpansaoFaltantes;
|
||||
self.estado = EstadoApp::ConfirmandoIntervalo {
|
||||
pre,
|
||||
series_excessivas: excessivos,
|
||||
};
|
||||
self.estado = EstadoApp::ConfirmandoIntervalo { pre };
|
||||
self.exibir_confirmacao(
|
||||
"Intervalo muito grande",
|
||||
format!(
|
||||
@@ -502,7 +505,8 @@ impl App {
|
||||
egui::TopBottomPanel::top("breadcrumb").show(ctx, |ui| {
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
for (i, label) in ["① Arquivo", "② Colunas", "③ Resultado"].iter().enumerate() {
|
||||
for (i, label) in ["① Arquivo", "② Colunas", "③ Resultado"].iter().enumerate()
|
||||
{
|
||||
let n = i + 1;
|
||||
let texto = egui::RichText::new(*label);
|
||||
if n == passo_ativo {
|
||||
@@ -552,8 +556,7 @@ impl eframe::App for App {
|
||||
let tela_id = self.tela_atual();
|
||||
|
||||
// Renderizar a tela atual
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
match tela_id {
|
||||
egui::CentralPanel::default().show(ctx, |ui| match tela_id {
|
||||
0 => crate::ui::screens::import::renderizar(ui, ctx, self),
|
||||
1 => crate::ui::screens::import::renderizar_selecao_aba(ui, ctx, self),
|
||||
2 => crate::ui::screens::configuracao_colunas::renderizar(ui, ctx, self),
|
||||
@@ -570,7 +573,6 @@ impl eframe::App for App {
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,16 @@ pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
||||
}
|
||||
});
|
||||
|
||||
if valido && !app.notas_importadas.is_empty() {
|
||||
if ui
|
||||
.button("🔄 Reanalisar")
|
||||
.on_hover_text("Reanalisa as notas já importadas sem reimportar o arquivo")
|
||||
.clicked()
|
||||
{
|
||||
app.executar_analise();
|
||||
}
|
||||
}
|
||||
|
||||
if ui.button("💾 Salvar como layout...").clicked() {
|
||||
app.exibir_modal_salvar_layout();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
use crate::application::usecases::importar_arquivo::{
|
||||
importar_csv, importar_xlsx, listar_abas_xlsx,
|
||||
};
|
||||
use crate::application::usecases::importar_arquivo::listar_abas_xlsx;
|
||||
use crate::domain::entities::layout::TipoArquivo;
|
||||
use crate::ui::app::{App, EstadoApp};
|
||||
use egui::{Context, Ui};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::application::usecases::layouts::{
|
||||
exportar_layout_json, importar_layout_json, salvar_layout,
|
||||
};
|
||||
use crate::domain::entities::layout::{Layout, LayoutCsv, LayoutXlsx, TipoArquivo};
|
||||
use crate::domain::entities::layout::{Layout, TipoArquivo};
|
||||
use crate::domain::errors::ErroLayout;
|
||||
use crate::ui::app::{AcaoModal, App, EstadoApp};
|
||||
use egui::{Context, Ui};
|
||||
@@ -83,7 +83,7 @@ pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
||||
|
||||
fn renderizar_secao_layouts(
|
||||
ui: &mut Ui,
|
||||
ctx: &Context,
|
||||
_ctx: &Context,
|
||||
app: &mut App,
|
||||
titulo: &str,
|
||||
layouts: &[Layout],
|
||||
|
||||
@@ -10,7 +10,7 @@ use egui::{Context, Ui};
|
||||
const OPCOES_PAGINA: &[usize] = &[50, 100, 200, 1000];
|
||||
|
||||
/// Renderiza a tela de resultados.
|
||||
pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
||||
pub fn renderizar(ui: &mut Ui, _ctx: &Context, app: &mut App) {
|
||||
// Extrair resultado do estado (sem mover)
|
||||
let resultado = match &app.estado {
|
||||
EstadoApp::ExibindoResultado(r) => r.clone(),
|
||||
|
||||
Reference in New Issue
Block a user