Implementado (3 features)

**Agrupamento de faltantes contíguos** (`detector_sequencia.rs`)
- Nova função pública `agrupar_contiguos(&[u64]) -> Vec<(u64, u64)>` com
  4 testes
- Na tela de resultado, faltantes agora aparecem como `• 100–104 (5
  notas)` em vez de 5 linhas separadas

**Estatísticas de completude** (`resultado.rs`)
- Cada série exibe: `Série 001 — 10 faltante(s) — 990/1000 notas (99.0%
  completo):`

**Copiar para clipboard** (`resultado.rs`)
- Botão `📋 Copiar` ao lado de cada série nos faltantes — copia todos os
  números (não só a página atual)
- Botão `📋 Copiar` nas duplicatas — copia no formato `1234 (3x), 5678
  (2x)`

Total: **42 → 46 testes**, todos passando.
This commit is contained in:
FelipeCN
2026-03-03 16:41:32 -03:00
parent 2c96e99ccc
commit b9052e073f
21 changed files with 558 additions and 778 deletions
+52 -25
View File
@@ -1,5 +1,6 @@
use crate::domain::{
entities::{
chave_serie::ChaveSerie,
nota::Nota,
resultado_analise::{ResultadoAnalise, ResultadoPreAnalise},
},
@@ -15,30 +16,31 @@ use std::collections::HashMap;
/// O caller deve verificar se algum intervalo excede `LIMITE_FALTANTES` e,
/// se sim, exibir confirmação ao usuário antes de chamar `expandir_analise`.
pub fn pre_analisar(notas: &[Nota]) -> ResultadoPreAnalise {
// Agrupar por série
let mut por_serie: HashMap<String, Vec<&Nota>> = HashMap::new();
// Agrupar por (serie, documento_tipo)
let mut por_chave: HashMap<ChaveSerie, Vec<&Nota>> = HashMap::new();
for nota in notas {
por_serie.entry(nota.serie.clone()).or_default().push(nota);
let chave = ChaveSerie::new(nota.serie.clone(), nota.documento_tipo.clone());
por_chave.entry(chave).or_default().push(nota);
}
let mut intervalos_por_serie = HashMap::new();
let mut soma_total = Decimal::ZERO;
let mut soma_por_serie: HashMap<String, Decimal> = HashMap::new();
let mut total_por_serie: HashMap<String, usize> = HashMap::new();
let mut soma_por_serie: HashMap<ChaveSerie, Decimal> = HashMap::new();
let mut total_por_serie: HashMap<ChaveSerie, usize> = HashMap::new();
for (serie, notas_serie) in &por_serie {
for (chave, notas_grupo) in &por_chave {
// Somar valores
for nota in notas_serie.iter() {
for nota in notas_grupo.iter() {
if let Some(v) = nota.valor {
soma_total += v;
*soma_por_serie.entry(serie.clone()).or_insert(Decimal::ZERO) += v;
*soma_por_serie.entry(chave.clone()).or_insert(Decimal::ZERO) += v;
}
}
*total_por_serie.entry(serie.clone()).or_insert(0) += notas_serie.len();
*total_por_serie.entry(chave.clone()).or_insert(0) += notas_grupo.len();
// Calcular intervalo de faltantes
if let Some(intervalo) = calcular_intervalo(notas_serie) {
intervalos_por_serie.insert(serie.clone(), intervalo);
if let Some(intervalo) = calcular_intervalo(notas_grupo) {
intervalos_por_serie.insert(chave.clone(), intervalo);
}
}
@@ -53,29 +55,30 @@ 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)> {
/// 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)> {
pre.intervalos_por_serie
.iter()
.filter(|(_, iv)| iv.excede_limite(LIMITE_FALTANTES))
.map(|(serie, iv)| (serie.clone(), iv.contagem_faltantes))
.map(|(chave, iv)| (chave.clone(), iv.contagem_faltantes))
.collect()
}
/// Expande a pré-análise para o resultado completo, materializando a lista de faltantes.
/// Deve ser chamado após confirmação do usuário (ou quando nenhum intervalo excede o limite).
pub fn expandir_analise(pre: ResultadoPreAnalise, notas: &[Nota]) -> ResultadoAnalise {
let mut por_serie: HashMap<String, Vec<&Nota>> = HashMap::new();
let mut por_chave: HashMap<ChaveSerie, Vec<&Nota>> = HashMap::new();
for nota in notas {
por_serie.entry(nota.serie.clone()).or_default().push(nota);
let chave = ChaveSerie::new(nota.serie.clone(), nota.documento_tipo.clone());
por_chave.entry(chave).or_default().push(nota);
}
let mut faltantes_por_serie = HashMap::new();
for (serie, notas_serie) in &por_serie {
let faltantes = detectar_faltantes(notas_serie);
faltantes_por_serie.insert(serie.clone(), faltantes);
for (chave, notas_grupo) in &por_chave {
let faltantes = detectar_faltantes(notas_grupo);
faltantes_por_serie.insert(chave.clone(), faltantes);
}
ResultadoAnalise {
@@ -100,7 +103,11 @@ mod tests {
}
fn nota(numero: u64, serie: &str, valor: Option<rust_decimal::Decimal>) -> Nota {
Nota::new(numero, serie.to_string(), valor, None)
Nota::new(numero, serie.to_string(), None, valor, None)
}
fn nota_com_tipo(numero: u64, serie: &str, tipo: &str) -> Nota {
Nota::new(numero, serie.to_string(), Some(tipo.to_string()), None, None)
}
#[test]
@@ -111,7 +118,8 @@ mod tests {
nota(4, "001", Some(dec!(50.00))),
];
let resultado = executar_analise(&notas);
assert_eq!(resultado.faltantes_por_serie["001"], vec![3u64]);
let chave = ChaveSerie::new("001".to_string(), None);
assert_eq!(resultado.faltantes_por_serie[&chave], vec![3u64]);
assert_eq!(resultado.soma_total, dec!(350.00));
}
@@ -124,8 +132,10 @@ mod tests {
nota(2, "002", None),
];
let resultado = executar_analise(&notas);
assert_eq!(resultado.faltantes_por_serie["001"], vec![2u64]);
assert!(resultado.faltantes_por_serie["002"].is_empty());
let chave001 = ChaveSerie::new("001".to_string(), None);
let chave002 = ChaveSerie::new("002".to_string(), None);
assert_eq!(resultado.faltantes_por_serie[&chave001], vec![2u64]);
assert!(resultado.faltantes_por_serie[&chave002].is_empty());
}
#[test]
@@ -134,6 +144,23 @@ mod tests {
let pre = pre_analisar(&notas);
let excessivos = series_com_intervalo_excessivo(&pre);
assert_eq!(excessivos.len(), 1);
assert_eq!(excessivos[0].0, "001");
assert_eq!(excessivos[0].0, ChaveSerie::new("001".to_string(), None));
}
#[test]
fn analise_nfe_nfce_mesma_serie_grupos_separados() {
// NFE série 001: 1, 3 → faltante 2
// NFCE série 001: 2, 5 → faltante 3 e 4
let notas = vec![
nota_com_tipo(1, "001", "NFE"),
nota_com_tipo(3, "001", "NFE"),
nota_com_tipo(2, "001", "NFCE"),
nota_com_tipo(5, "001", "NFCE"),
];
let resultado = executar_analise(&notas);
let chave_nfe = ChaveSerie::new("001".to_string(), Some("NFE".to_string()));
let chave_nfce = ChaveSerie::new("001".to_string(), Some("NFCE".to_string()));
assert_eq!(resultado.faltantes_por_serie[&chave_nfe], vec![2u64]);
assert_eq!(resultado.faltantes_por_serie[&chave_nfce], vec![3u64, 4u64]);
}
}
+19 -1
View File
@@ -40,6 +40,7 @@ pub fn importar_csv(
&resultado.linhas,
config.indice_numero,
config.indice_serie,
config.indice_documento_tipo,
config.indice_valor,
config.indice_data,
&mut avisos,
@@ -86,12 +87,18 @@ pub fn importar_xlsx(
.as_ref()
.and_then(|p| xlsx_reader::parsear_letra_linha(p))
.map(|c| c.coluna as usize);
let col_documento_tipo = config
.pos_documento_tipo
.as_ref()
.and_then(|p| xlsx_reader::parsear_letra_linha(p))
.map(|c| c.coluna as usize);
let mut avisos = resultado.avisos;
let notas = mapear_linhas_para_notas(
&resultado.linhas,
col_numero,
col_serie,
col_documento_tipo,
col_valor,
col_data,
&mut avisos,
@@ -106,6 +113,7 @@ fn mapear_linhas_para_notas(
linhas: &[Vec<String>],
idx_numero: usize,
idx_serie: usize,
idx_documento_tipo: Option<usize>,
idx_valor: Option<usize>,
idx_data: Option<usize>,
avisos: &mut ResumoAvisos,
@@ -195,7 +203,17 @@ fn mapear_linhas_para_notas(
None
};
notas.push(Nota::new(numero, serie, valor, data));
// Extrair tipo de documento (opcional) — qualquer string não vazia
let documento_tipo = if let Some(idx) = idx_documento_tipo {
match linha.get(idx) {
Some(s) if !s.trim().is_empty() => Some(s.trim().to_string()),
_ => None,
}
} else {
None
};
notas.push(Nota::new(numero, serie, documento_tipo, valor, data));
}
notas
+26
View File
@@ -0,0 +1,26 @@
/// Chave composta que identifica um grupo de notas fiscais.
/// Combina a série com o tipo de documento (ex: NFE, NFCE), ambos opcionais.
/// Quando `documento_tipo` é `None`, o comportamento é idêntico ao agrupamento
/// somente por série (retrocompatível).
#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
pub struct ChaveSerie {
pub serie: String,
pub documento_tipo: Option<String>,
}
impl ChaveSerie {
pub fn new(serie: String, documento_tipo: Option<String>) -> Self {
Self {
serie,
documento_tipo,
}
}
/// Formata para exibição: "001 / NFE" quando tipo presente, "001" quando ausente.
pub fn label(&self) -> String {
match &self.documento_tipo {
Some(tipo) => format!("{} / {}", self.serie, tipo),
None => self.serie.clone(),
}
}
}
+16
View File
@@ -34,6 +34,8 @@ pub struct LayoutCsv {
pub indice_valor: Option<usize>,
/// Índice da coluna Data (base 0, None se não mapeado)
pub indice_data: Option<usize>,
/// Índice da coluna Tipo Documento (base 0, None se não mapeado)
pub indice_documento_tipo: Option<usize>,
}
impl Default for LayoutCsv {
@@ -46,6 +48,7 @@ impl Default for LayoutCsv {
indice_serie: 1,
indice_valor: None,
indice_data: None,
indice_documento_tipo: None,
}
}
}
@@ -63,6 +66,8 @@ pub struct LayoutXlsx {
pub pos_valor: Option<String>,
/// Posição inicial da coluna Data (None se não mapeado)
pub pos_data: Option<String>,
/// Posição inicial da coluna Tipo Documento (None se não mapeado)
pub pos_documento_tipo: Option<String>,
}
impl Default for LayoutXlsx {
@@ -73,6 +78,7 @@ impl Default for LayoutXlsx {
pos_serie: String::new(),
pos_valor: None,
pos_data: None,
pos_documento_tipo: None,
}
}
}
@@ -129,6 +135,8 @@ pub enum LayoutJson {
indice_serie: usize,
indice_valor: Option<usize>,
indice_data: Option<usize>,
#[serde(default)]
indice_documento_tipo: Option<usize>,
},
Xlsx {
nome: String,
@@ -137,6 +145,8 @@ pub enum LayoutJson {
pos_serie: String,
pos_valor: Option<String>,
pos_data: Option<String>,
#[serde(default)]
pos_documento_tipo: Option<String>,
},
}
@@ -154,6 +164,7 @@ impl TryFrom<LayoutJson> for Layout {
indice_serie,
indice_valor,
indice_data,
indice_documento_tipo,
} => {
if nome.trim().is_empty() {
return Err(crate::domain::errors::ErroLayout::CampoObrigatorioAusente(
@@ -176,6 +187,7 @@ impl TryFrom<LayoutJson> for Layout {
indice_serie,
indice_valor,
indice_data,
indice_documento_tipo,
},
})
}
@@ -186,6 +198,7 @@ impl TryFrom<LayoutJson> for Layout {
pos_serie,
pos_valor,
pos_data,
pos_documento_tipo,
} => {
if nome.trim().is_empty() {
return Err(crate::domain::errors::ErroLayout::CampoObrigatorioAusente(
@@ -211,6 +224,7 @@ impl TryFrom<LayoutJson> for Layout {
pos_serie,
pos_valor,
pos_data,
pos_documento_tipo,
},
})
}
@@ -230,6 +244,7 @@ impl From<&Layout> for LayoutJson {
indice_serie: config.indice_serie,
indice_valor: config.indice_valor,
indice_data: config.indice_data,
indice_documento_tipo: config.indice_documento_tipo,
},
Layout::Xlsx { nome, config, .. } => LayoutJson::Xlsx {
nome: nome.clone(),
@@ -238,6 +253,7 @@ impl From<&Layout> for LayoutJson {
pos_serie: config.pos_serie.clone(),
pos_valor: config.pos_valor.clone(),
pos_data: config.pos_data.clone(),
pos_documento_tipo: config.pos_documento_tipo.clone(),
},
}
}
+1
View File
@@ -2,3 +2,4 @@ pub mod layout;
pub mod nota;
pub mod resultado_analise;
pub mod serie;
pub mod chave_serie;
+5 -1
View File
@@ -2,13 +2,15 @@ use chrono::NaiveDate;
use rust_decimal::Decimal;
/// Representa uma nota fiscal com seus campos lógicos.
/// `numero + serie` é o identificador único de cada nota.
/// `numero + serie + documento_tipo` é o identificador único de cada nota.
#[derive(Debug, Clone)]
pub struct Nota {
/// Número incremental da nota. Armazenado como u64.
pub numero: u64,
/// Série da nota (13 dígitos numéricos). Ex: "001", "1".
pub serie: String,
/// Tipo de documento (ex: "NFE", "NFCE"). None quando não mapeado.
pub documento_tipo: Option<String>,
/// Valor monetário da nota (opcional).
pub valor: Option<Decimal>,
/// Data de emissão da nota (opcional, exibida no PDF mas não usada em regras).
@@ -19,12 +21,14 @@ impl Nota {
pub fn new(
numero: u64,
serie: String,
documento_tipo: Option<String>,
valor: Option<Decimal>,
data: Option<NaiveDate>,
) -> Self {
Self {
numero,
serie,
documento_tipo,
valor,
data,
}
+15 -14
View File
@@ -1,3 +1,4 @@
use crate::domain::entities::chave_serie::ChaveSerie;
use rust_decimal::Decimal;
use std::collections::HashMap;
@@ -5,15 +6,15 @@ use std::collections::HashMap;
/// Usado para verificar se algum intervalo excede 10.000 registros (RF04).
#[derive(Debug, Clone)]
pub struct ResultadoPreAnalise {
/// Mapa série → (minimo, maximo, contagem_faltantes)
pub intervalos_por_serie: HashMap<String, IntervaloSerie>,
/// Mapa (serie, documento_tipo) → (minimo, maximo, contagem_faltantes)
pub intervalos_por_serie: HashMap<ChaveSerie, IntervaloSerie>,
/// Duplicatas já processadas (não dependem dos faltantes)
pub duplicadas_por_serie: HashMap<String, Vec<(u64, usize)>>,
pub duplicadas_por_serie: HashMap<ChaveSerie, Vec<(u64, usize)>>,
/// Somas já calculadas
pub soma_total: Decimal,
pub soma_por_serie: HashMap<String, Decimal>,
/// Total de notas processadas por série
pub total_por_serie: HashMap<String, usize>,
pub soma_por_serie: HashMap<ChaveSerie, Decimal>,
/// Total de notas processadas por (serie, documento_tipo)
pub total_por_serie: HashMap<ChaveSerie, usize>,
}
/// Intervalo de sequência de uma série.
@@ -33,16 +34,16 @@ impl IntervaloSerie {
/// Resultado completo da análise, com a lista materializada de faltantes.
#[derive(Debug, Clone)]
pub struct ResultadoAnalise {
/// Números faltantes por série (ordenados crescentemente)
pub faltantes_por_serie: HashMap<String, Vec<u64>>,
/// Duplicatas: mapa série → [(numero, contagem_ocorrencias)]
pub duplicadas_por_serie: HashMap<String, Vec<(u64, usize)>>,
/// Números faltantes por (serie, documento_tipo) (ordenados crescentemente)
pub faltantes_por_serie: HashMap<ChaveSerie, Vec<u64>>,
/// Duplicatas: mapa (serie, documento_tipo) → [(numero, contagem_ocorrencias)]
pub duplicadas_por_serie: HashMap<ChaveSerie, Vec<(u64, usize)>>,
/// Soma total de todos os valores
pub soma_total: Decimal,
/// Soma por série
pub soma_por_serie: HashMap<String, Decimal>,
/// Total de notas processadas por série
pub total_por_serie: HashMap<String, usize>,
/// Soma por (serie, documento_tipo)
pub soma_por_serie: HashMap<ChaveSerie, Decimal>,
/// Total de notas processadas por (serie, documento_tipo)
pub total_por_serie: HashMap<ChaveSerie, usize>,
}
impl ResultadoAnalise {
+38 -19
View File
@@ -1,16 +1,17 @@
use crate::domain::entities::chave_serie::ChaveSerie;
use crate::domain::entities::nota::Nota;
use std::collections::HashMap;
/// Detecta registros duplicados em uma lista de notas.
///
/// Retorna um mapa (numero, serie) → contagem de ocorrências,
/// Retorna um mapa (numero, serie, documento_tipo) → contagem de ocorrências,
/// contendo apenas grupos com mais de uma ocorrência.
pub fn detectar_duplicidades(notas: &[Nota]) -> HashMap<(u64, String), usize> {
let mut contagem: HashMap<(u64, String), usize> = HashMap::new();
pub fn detectar_duplicidades(notas: &[Nota]) -> HashMap<(u64, String, Option<String>), usize> {
let mut contagem: HashMap<(u64, String, Option<String>), usize> = HashMap::new();
for nota in notas {
*contagem
.entry((nota.numero, nota.serie.clone()))
.entry((nota.numero, nota.serie.clone(), nota.documento_tipo.clone()))
.or_insert(0) += 1;
}
@@ -19,18 +20,19 @@ pub fn detectar_duplicidades(notas: &[Nota]) -> HashMap<(u64, String), usize> {
contagem
}
/// Agrupa as duplicidades por série.
/// Agrupa as duplicidades por (serie, documento_tipo).
///
/// Retorna HashMap<serie, Vec<(numero, contagem)>>, ordenado por numero crescente.
pub fn duplicidades_por_serie(notas: &[Nota]) -> HashMap<String, Vec<(u64, usize)>> {
/// Retorna HashMap<ChaveSerie, Vec<(numero, contagem)>>, ordenado por numero crescente.
pub fn duplicidades_por_serie(notas: &[Nota]) -> HashMap<ChaveSerie, Vec<(u64, usize)>> {
let raw = detectar_duplicidades(notas);
let mut result: HashMap<String, Vec<(u64, usize)>> = HashMap::new();
let mut result: HashMap<ChaveSerie, Vec<(u64, usize)>> = HashMap::new();
for ((numero, serie), contagem) in raw {
result.entry(serie).or_default().push((numero, contagem));
for ((numero, serie, documento_tipo), contagem) in raw {
let chave = ChaveSerie::new(serie, documento_tipo);
result.entry(chave).or_default().push((numero, contagem));
}
// Ordenar por numero dentro de cada série
// Ordenar por numero dentro de cada grupo
for lista in result.values_mut() {
lista.sort_by_key(|(num, _)| *num);
}
@@ -44,7 +46,11 @@ mod tests {
use crate::domain::entities::nota::Nota;
fn nota(numero: u64, serie: &str) -> Nota {
Nota::new(numero, serie.to_string(), None, None)
Nota::new(numero, serie.to_string(), None, None, None)
}
fn nota_com_tipo(numero: u64, serie: &str, tipo: &str) -> Nota {
Nota::new(numero, serie.to_string(), Some(tipo.to_string()), None, None)
}
#[test]
@@ -58,7 +64,7 @@ mod tests {
fn com_duplicata_simples() {
let notas = vec![nota(1, "001"), nota(1, "001"), nota(2, "001")];
let dup = detectar_duplicidades(&notas);
assert_eq!(dup.get(&(1, "001".to_string())), Some(&2));
assert_eq!(dup.get(&(1, "001".to_string(), None)), Some(&2));
assert_eq!(dup.len(), 1);
}
@@ -66,17 +72,30 @@ mod tests {
fn duplicata_multiplas_ocorrencias() {
let notas = vec![nota(4, "001"), nota(4, "001"), nota(4, "001")];
let dup = detectar_duplicidades(&notas);
assert_eq!(dup.get(&(4, "001".to_string())), Some(&3));
assert_eq!(dup.get(&(4, "001".to_string(), None)), Some(&3));
}
#[test]
fn mesmo_numero_series_diferentes_nao_e_duplicata() {
// Número 1 em séries diferentes não é duplicata
let notas = vec![nota(1, "001"), nota(1, "002")];
let dup = detectar_duplicidades(&notas);
assert!(dup.is_empty());
}
#[test]
fn mesmo_numero_serie_tipos_diferentes_nao_e_duplicata() {
let notas = vec![nota_com_tipo(1, "001", "NFE"), nota_com_tipo(1, "001", "NFCE")];
let dup = detectar_duplicidades(&notas);
assert!(dup.is_empty());
}
#[test]
fn mesmo_numero_serie_tipo_igual_e_duplicata() {
let notas = vec![nota_com_tipo(1, "001", "NFE"), nota_com_tipo(1, "001", "NFE")];
let dup = detectar_duplicidades(&notas);
assert_eq!(dup.get(&(1, "001".to_string(), Some("NFE".to_string()))), Some(&2));
}
#[test]
fn agrupamento_por_serie() {
let notas = vec![
@@ -88,13 +107,13 @@ mod tests {
nota(2, "001"),
];
let por_serie = duplicidades_por_serie(&notas);
// Série 001 deve ter notas 1 e 2 duplicadas
let serie001 = por_serie.get("001").unwrap();
let chave001 = ChaveSerie::new("001".to_string(), None);
let chave002 = ChaveSerie::new("002".to_string(), None);
let serie001 = por_serie.get(&chave001).unwrap();
assert_eq!(serie001.len(), 2);
assert_eq!(serie001[0], (1, 2));
assert_eq!(serie001[1], (2, 2));
// Série 002 deve ter nota 1 duplicada
let serie002 = por_serie.get("002").unwrap();
let serie002 = por_serie.get(&chave002).unwrap();
assert_eq!(serie002.len(), 1);
assert_eq!(serie002[0], (1, 2));
}
+50 -1
View File
@@ -67,6 +67,32 @@ pub fn detectar_faltantes(notas: &[&Nota]) -> Vec<u64> {
faltantes
}
/// Agrupa uma lista **ordenada** de faltantes em intervalos contíguos.
///
/// Retorna pares `(inicio, fim)`. Números isolados têm `inicio == fim`.
///
/// # Exemplo
/// `[1, 2, 3, 5, 8, 9]` → `[(1, 3), (5, 5), (8, 9)]`
pub fn agrupar_contiguos(faltantes: &[u64]) -> Vec<(u64, u64)> {
if faltantes.is_empty() {
return Vec::new();
}
let mut grupos = Vec::new();
let mut inicio = faltantes[0];
let mut anterior = faltantes[0];
for &num in &faltantes[1..] {
if num != anterior + 1 {
grupos.push((inicio, anterior));
inicio = num;
}
anterior = num;
}
grupos.push((inicio, anterior));
grupos
}
#[cfg(test)]
mod tests {
use super::*;
@@ -74,7 +100,7 @@ mod tests {
use rust_decimal::Decimal;
fn nota(numero: u64) -> Nota {
Nota::new(numero, "001".to_string(), None, None)
Nota::new(numero, "001".to_string(), None, None, None)
}
#[test]
@@ -136,4 +162,27 @@ mod tests {
let refs: Vec<&Nota> = notas.iter().collect();
assert_eq!(detectar_faltantes(&refs), Vec::<u64>::new());
}
#[test]
fn agrupar_vazio() {
assert_eq!(agrupar_contiguos(&[]), Vec::<(u64, u64)>::new());
}
#[test]
fn agrupar_isolados() {
assert_eq!(agrupar_contiguos(&[1, 3, 5]), vec![(1, 1), (3, 3), (5, 5)]);
}
#[test]
fn agrupar_um_intervalo() {
assert_eq!(agrupar_contiguos(&[1, 2, 3]), vec![(1, 3)]);
}
#[test]
fn agrupar_misto() {
assert_eq!(
agrupar_contiguos(&[1, 2, 3, 5, 8, 9]),
vec![(1, 3), (5, 5), (8, 9)]
);
}
}
+47 -52
View File
@@ -1,3 +1,4 @@
use crate::domain::entities::chave_serie::ChaveSerie;
use crate::domain::entities::resultado_analise::ResultadoAnalise;
use crate::domain::services::parser_monetario::formatar_valor_br;
use chrono::{DateTime, Local};
@@ -7,6 +8,14 @@ use genpdf::{
};
use std::path::Path;
// Fontes embutidas no binário em tempo de compilação.
// Liberation Sans (~402 KB cada) substitui Arial do sistema (~993 KB cada),
// eliminando dependência de fonte externa e reduzindo o tamanho dos PDFs gerados.
const FONT_REGULAR: &[u8] =
include_bytes!("../../assets/fonts/LiberationSans-Regular.ttf");
const FONT_BOLD: &[u8] =
include_bytes!("../../assets/fonts/LiberationSans-Bold.ttf");
/// Metadados do relatório.
pub struct MetadadosRelatorio {
pub nome_arquivo: String,
@@ -36,9 +45,7 @@ impl PdfGenerator for GenpdfGenerator {
meta: &MetadadosRelatorio,
caminho_saida: &Path,
) -> Result<(), String> {
// Carregar fonte do sistema (DejaVu Sans)
let font_family = carregar_fonte_sistema()
.map_err(|e| format!("Erro ao carregar fontes: {}", e))?;
let font_family = carregar_fonte_familia()?;
let mut doc = Document::new(font_family);
doc.set_title("Relatório — Comparador de Notas");
@@ -68,6 +75,10 @@ impl PdfGenerator for GenpdfGenerator {
)));
doc.push(Break::new(1));
// Ordenar chaves
let mut chaves_ordenadas: Vec<&ChaveSerie> = resultado.soma_por_serie.keys().collect();
chaves_ordenadas.sort();
// Totais
doc.push(
Paragraph::new("").styled_string("Totais", style::Style::new().bold().with_font_size(14)),
@@ -77,15 +88,12 @@ impl PdfGenerator for GenpdfGenerator {
formatar_valor_br(&resultado.soma_total)
)));
let mut series_ordenadas: Vec<&String> = resultado.soma_por_serie.keys().collect();
series_ordenadas.sort();
for serie in &series_ordenadas {
let soma = &resultado.soma_por_serie[*serie];
let total = resultado.total_por_serie.get(*serie).copied().unwrap_or(0);
for chave in &chaves_ordenadas {
let soma = &resultado.soma_por_serie[*chave];
let total = resultado.total_por_serie.get(*chave).copied().unwrap_or(0);
doc.push(Paragraph::new(format!(
" Série {}: {} nota(s) — R$ {}",
serie,
chave.label(),
total,
formatar_valor_br(soma)
)));
@@ -98,18 +106,22 @@ impl PdfGenerator for GenpdfGenerator {
Paragraph::new("").styled_string("Notas Faltantes por Série", style::Style::new().bold().with_font_size(14)),
);
for serie in &series_ordenadas {
let faltantes = match resultado.faltantes_por_serie.get(*serie) {
// Use faltantes keys for this section (may differ from soma keys if no values)
let mut chaves_faltantes: Vec<&ChaveSerie> = resultado.faltantes_por_serie.keys().collect();
chaves_faltantes.sort();
for chave in &chaves_faltantes {
let faltantes = match resultado.faltantes_por_serie.get(*chave) {
Some(f) if !f.is_empty() => f,
_ => {
doc.push(Paragraph::new(format!(" Série {}: nenhuma faltante", serie)));
doc.push(Paragraph::new(format!(" Série {}: nenhuma faltante", chave.label())));
continue;
}
};
doc.push(Paragraph::new(format!(
" Série {}: {} faltante(s)",
serie,
chave.label(),
faltantes.len()
)));
let numeros: Vec<String> = faltantes.iter().map(|n| n.to_string()).collect();
@@ -123,24 +135,27 @@ impl PdfGenerator for GenpdfGenerator {
Paragraph::new("").styled_string("Duplicatas por Série", style::Style::new().bold().with_font_size(14)),
);
for serie in &series_ordenadas {
let duplicatas = match resultado.duplicadas_por_serie.get(*serie) {
let mut chaves_dup: Vec<&ChaveSerie> = resultado.duplicadas_por_serie.keys().collect();
chaves_dup.sort();
for chave in &chaves_dup {
let duplicatas = match resultado.duplicadas_por_serie.get(*chave) {
Some(d) if !d.is_empty() => d,
_ => {
doc.push(Paragraph::new(format!(" Série {}: nenhuma duplicata", serie)));
doc.push(Paragraph::new(format!(" Série {}: nenhuma duplicata", chave.label())));
continue;
}
};
doc.push(Paragraph::new(format!(
" Série {}: {} grupo(s) duplicado(s)",
serie,
chave.label(),
duplicatas.len()
)));
for (numero, count) in duplicatas {
doc.push(Paragraph::new(format!(
" NF {} / Série {}{} ocorrências",
numero, serie, count
numero, chave.label(), count
)));
}
}
@@ -153,41 +168,21 @@ impl PdfGenerator for GenpdfGenerator {
}
}
/// Tenta carregar fontes DejaVu Sans do sistema.
fn carregar_fonte_sistema() -> Result<fonts::FontFamily<fonts::FontData>, String> {
// Caminhos comuns no Linux, Windows e macOS
let candidatos_regular = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf",
"C:\\Windows\\Fonts\\arial.ttf",
"/Library/Fonts/Arial.ttf",
];
let candidatos_bold = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
"C:\\Windows\\Fonts\\arialbd.ttf",
"/Library/Fonts/Arial Bold.ttf",
];
let regular_path = candidatos_regular
.iter()
.find(|p| std::path::Path::new(p).exists())
.ok_or_else(|| "Fonte regular não encontrada no sistema".to_string())?;
let bold_path = candidatos_bold
.iter()
.find(|p| std::path::Path::new(p).exists())
.unwrap_or(regular_path);
let regular = fonts::FontData::load(regular_path, None)
.map_err(|e| format!("Erro ao carregar fonte regular: {}", e))?;
let bold = fonts::FontData::load(bold_path, None)
.map_err(|e| format!("Erro ao carregar fonte bold: {}", e))?;
/// Constrói a família de fontes a partir dos bytes embutidos no binário.
/// Usa Liberation Sans (open-source, ~402 KB/variante) em vez de carregar
/// fontes do sistema (Arial ~993 KB/variante, sem subsetting).
fn carregar_fonte_familia() -> Result<fonts::FontFamily<fonts::FontData>, String> {
let regular = fonts::FontData::new(FONT_REGULAR.to_vec(), None)
.map_err(|e| format!("Erro ao inicializar fonte regular: {}", e))?;
let bold = fonts::FontData::new(FONT_BOLD.to_vec(), None)
.map_err(|e| format!("Erro ao inicializar fonte bold: {}", e))?;
// genpdf exige os 4 slots do FontFamily. Como o relatório nunca usa itálico,
// italic e bold_italic reusam os dados de regular/bold respectivamente.
Ok(fonts::FontFamily {
regular: regular.clone(),
bold,
italic: regular.clone(),
bold_italic: regular,
bold_italic: bold.clone(),
regular,
bold,
})
}
+20 -9
View File
@@ -8,8 +8,9 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
conn.execute(
"INSERT INTO layouts
(nome, tipo, delimitador, encoding, linha_cabecalho,
indice_numero, indice_serie, indice_valor, indice_data)
VALUES (?1, 'csv', ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
indice_numero, indice_serie, indice_valor, indice_data,
indice_documento_tipo)
VALUES (?1, 'csv', ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
nome,
config.delimitador.to_string(),
@@ -19,6 +20,7 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
config.indice_serie as i64,
config.indice_valor.map(|v| v as i64),
config.indice_data.map(|v| v as i64),
config.indice_documento_tipo.map(|v| v as i64),
],
)?;
Ok(conn.last_insert_rowid())
@@ -26,8 +28,9 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
Layout::Xlsx { nome, config, .. } => {
conn.execute(
"INSERT INTO layouts
(nome, tipo, aba, pos_numero, pos_serie, pos_valor, pos_data)
VALUES (?1, 'xlsx', ?2, ?3, ?4, ?5, ?6)",
(nome, tipo, aba, pos_numero, pos_serie, pos_valor, pos_data,
pos_documento_tipo)
VALUES (?1, 'xlsx', ?2, ?3, ?4, ?5, ?6, ?7)",
params![
nome,
config.aba,
@@ -35,6 +38,7 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
config.pos_serie,
config.pos_valor,
config.pos_data,
config.pos_documento_tipo,
],
)?;
Ok(conn.last_insert_rowid())
@@ -54,8 +58,8 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
"UPDATE layouts SET
nome = ?1, delimitador = ?2, encoding = ?3,
linha_cabecalho = ?4, indice_numero = ?5, indice_serie = ?6,
indice_valor = ?7, indice_data = ?8
WHERE id = ?9",
indice_valor = ?7, indice_data = ?8, indice_documento_tipo = ?9
WHERE id = ?10",
params![
nome,
config.delimitador.to_string(),
@@ -65,6 +69,7 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
config.indice_serie as i64,
config.indice_valor.map(|v| v as i64),
config.indice_data.map(|v| v as i64),
config.indice_documento_tipo.map(|v| v as i64),
id,
],
)?;
@@ -73,8 +78,8 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
conn.execute(
"UPDATE layouts SET
nome = ?1, aba = ?2, pos_numero = ?3, pos_serie = ?4,
pos_valor = ?5, pos_data = ?6
WHERE id = ?7",
pos_valor = ?5, pos_data = ?6, pos_documento_tipo = ?7
WHERE id = ?8",
params![
nome,
config.aba,
@@ -82,6 +87,7 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
config.pos_serie,
config.pos_valor,
config.pos_data,
config.pos_documento_tipo,
id,
],
)?;
@@ -96,7 +102,8 @@ pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
"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
aba, pos_numero, pos_serie, pos_valor, pos_data,
indice_documento_tipo, pos_documento_tipo
FROM layouts ORDER BY nome ASC",
)?;
@@ -124,6 +131,9 @@ pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
indice_data: row
.get::<_, Option<i64>>(9)?
.map(|v| v as usize),
indice_documento_tipo: row
.get::<_, Option<i64>>(15)?
.map(|v| v as usize),
},
})
} else {
@@ -136,6 +146,7 @@ pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
pos_serie: row.get(12)?,
pos_valor: row.get(13)?,
pos_data: row.get(14)?,
pos_documento_tipo: row.get(16)?,
},
})
}
+14 -1
View File
@@ -1,7 +1,7 @@
use rusqlite::{Connection, Result};
/// Versão atual do schema do banco de dados.
const VERSAO_SCHEMA_ATUAL: i64 = 2;
const VERSAO_SCHEMA_ATUAL: i64 = 3;
/// Aplica todas as migrations necessárias para atualizar o banco
/// para a versão mais recente.
@@ -27,6 +27,9 @@ pub fn aplicar_migrations(conn: &Connection) -> Result<()> {
if versao_atual < 2 {
migration_v2(conn)?;
}
if versao_atual < 3 {
migration_v3(conn)?;
}
if versao_atual == 0 {
conn.execute("INSERT INTO schema_version (versao) VALUES (?1);", [VERSAO_SCHEMA_ATUAL])?;
@@ -38,6 +41,16 @@ pub fn aplicar_migrations(conn: &Connection) -> Result<()> {
}
/// 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(
"ALTER TABLE layouts ADD COLUMN indice_documento_tipo INTEGER;
ALTER TABLE layouts ADD COLUMN pos_documento_tipo TEXT;",
)?;
Ok(())
}
/// 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<()> {
+6 -5
View File
@@ -4,6 +4,7 @@ use crate::application::usecases::{
};
use crate::domain::{
entities::{
chave_serie::ChaveSerie,
layout::{Layout, LayoutCsv, LayoutXlsx, TipoArquivo},
nota::Nota,
resultado_analise::{ResultadoAnalise, ResultadoPreAnalise},
@@ -82,7 +83,7 @@ pub enum ResultadoPendente {
/// Pré-análise concluída mas precisa de confirmação do usuário.
AguardandoConfirmacao {
pre: ResultadoPreAnalise,
series_excessivas: Vec<(String, u64)>,
series_excessivas: Vec<(ChaveSerie, u64)>,
avisos: ResumoAvisos,
notas: Vec<Nota>,
},
@@ -464,10 +465,10 @@ impl App {
};
let msg = series_excessivas
.iter()
.map(|(serie, count)| {
.map(|(chave, count)| {
format!(
"Série {}: intervalo de {} faltantes detectado",
serie, count
chave.label(), count
)
})
.collect::<Vec<_>>()
@@ -506,10 +507,10 @@ impl App {
if !excessivos.is_empty() {
let msg = excessivos
.iter()
.map(|(serie, count)| {
.map(|(chave, count)| {
format!(
"Série {}: intervalo de {} faltantes detectado",
serie, count
chave.label(), count
)
})
.collect::<Vec<_>>()
+13
View File
@@ -233,6 +233,11 @@ fn renderizar_csv(ui: &mut Ui, app: &mut App) {
"Data (opcional):",
&mut app.layout_csv_atual.indice_data,
);
campo_indice_opcional(
ui,
"Tipo Documento (opcional):",
&mut app.layout_csv_atual.indice_documento_tipo,
);
});
}
@@ -281,6 +286,11 @@ fn renderizar_xlsx(ui: &mut Ui, app: &mut App) {
&mut app.layout_xlsx_atual.pos_valor,
);
campo_letra_linha_opcional(ui, "Data (opcional):", &mut app.layout_xlsx_atual.pos_data);
campo_letra_linha_opcional(
ui,
"Tipo Documento (opcional):",
&mut app.layout_xlsx_atual.pos_documento_tipo,
);
});
}
@@ -340,6 +350,9 @@ fn validar_config(app: &App) -> (bool, Vec<String>) {
if let Some(d) = c.indice_data {
indices.push(("Data".to_string(), d));
}
if let Some(t) = c.indice_documento_tipo {
indices.push(("Tipo Documento".to_string(), t));
}
verificar_duplicados(&indices, &mut erros);
}
TipoArquivo::Xlsx => {
+62 -36
View File
@@ -1,7 +1,7 @@
use crate::application::usecases::exportar_pdf::exportar_pdf;
use crate::domain::{
entities::resultado_analise::ResultadoAnalise,
services::parser_monetario::formatar_valor_br,
entities::{chave_serie::ChaveSerie, resultado_analise::ResultadoAnalise},
services::{detector_sequencia::agrupar_contiguos, parser_monetario::formatar_valor_br},
};
use crate::infrastructure::pdf_generator::GenpdfGenerator;
use crate::ui::app::{AcaoModal, App, EstadoApp};
@@ -86,15 +86,15 @@ fn renderizar_totais(ui: &mut Ui, resultado: &ResultadoAnalise) {
formatar_valor_br(&resultado.soma_total)
));
let mut series: Vec<&String> = resultado.soma_por_serie.keys().collect();
series.sort();
let mut chaves: Vec<&ChaveSerie> = resultado.soma_por_serie.keys().collect();
chaves.sort();
for serie in series {
let soma = &resultado.soma_por_serie[serie];
let total_notas = resultado.total_por_serie.get(serie).copied().unwrap_or(0);
for chave in chaves {
let soma = &resultado.soma_por_serie[chave];
let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0);
ui.label(format!(
" Série {}: {} nota(s) — R$ {}",
serie,
chave.label(),
total_notas,
formatar_valor_br(soma)
));
@@ -104,12 +104,9 @@ fn renderizar_totais(ui: &mut Ui, resultado: &ResultadoAnalise) {
fn renderizar_faltantes(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise) {
let total_faltantes = resultado.total_faltantes();
ui.label(
egui::RichText::new(format!(
"Notas Faltantes ({} total)",
total_faltantes
))
.heading()
.strong(),
egui::RichText::new(format!("Notas Faltantes ({} total)", total_faltantes))
.heading()
.strong(),
);
ui.add_space(4.0);
@@ -118,22 +115,36 @@ fn renderizar_faltantes(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise
return;
}
let mut series: Vec<&String> = resultado.faltantes_por_serie.keys().collect();
series.sort();
let mut chaves: Vec<&ChaveSerie> = resultado.faltantes_por_serie.keys().collect();
chaves.sort();
for serie in series {
let faltantes = &resultado.faltantes_por_serie[serie];
for chave in chaves {
let faltantes = &resultado.faltantes_por_serie[chave];
if faltantes.is_empty() {
continue;
}
ui.label(format!(
"Série {}{} faltante(s):",
serie,
faltantes.len()
));
// Estatística de completude por série
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 * 100.0;
// Paginação
ui.horizontal(|ui| {
ui.label(format!(
"Série {}{} faltante(s) — {}/{} notas ({:.1}% completo):",
chave.label(),
faltantes.len(),
total_notas,
total_esperado,
percentual,
));
if ui.button("📋 Copiar").on_hover_text("Copiar todos os números faltantes").clicked() {
let texto = faltantes.iter().map(|n| n.to_string()).collect::<Vec<_>>().join(", ");
ui.ctx().copy_text(texto);
}
});
// Paginação (por faltante individual)
let total_paginas = (faltantes.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
if app.pagina_faltantes >= total_paginas {
app.pagina_faltantes = 0;
@@ -142,8 +153,13 @@ fn renderizar_faltantes(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise
let inicio = app.pagina_faltantes * app.itens_por_pagina;
let fim = (inicio + app.itens_por_pagina).min(faltantes.len());
for numero in &faltantes[inicio..fim] {
ui.label(format!("{}", numero));
// Exibir grupos contíguos da página atual
for (a, b) in agrupar_contiguos(&faltantes[inicio..fim]) {
if a == b {
ui.label(format!("{}", a));
} else {
ui.label(format!("{}{} ({} notas)", a, b, b - a + 1));
}
}
if total_paginas > 1 {
@@ -181,20 +197,30 @@ fn renderizar_duplicatas(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalis
return;
}
let mut series: Vec<&String> = resultado.duplicadas_por_serie.keys().collect();
series.sort();
let mut chaves: Vec<&ChaveSerie> = resultado.duplicadas_por_serie.keys().collect();
chaves.sort();
for serie in series {
let duplicatas = &resultado.duplicadas_por_serie[serie];
for chave in chaves {
let duplicatas = &resultado.duplicadas_por_serie[chave];
if duplicatas.is_empty() {
continue;
}
ui.label(format!(
"Série {}{} grupo(s) duplicado(s):",
serie,
duplicatas.len()
));
ui.horizontal(|ui| {
ui.label(format!(
"Série {}{} grupo(s) duplicado(s):",
chave.label(),
duplicatas.len()
));
if ui.button("📋 Copiar").on_hover_text("Copiar números duplicados").clicked() {
let texto = duplicatas
.iter()
.map(|(n, c)| format!("{} ({}x)", n, c))
.collect::<Vec<_>>()
.join(", ");
ui.ctx().copy_text(texto);
}
});
let total_paginas =
(duplicatas.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
@@ -208,7 +234,7 @@ fn renderizar_duplicatas(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalis
for (numero, count) in &duplicatas[inicio..fim] {
ui.label(format!(
" • NF {} / Série {}{} ocorrências",
numero, serie, count
numero, chave.label(), count
));
}