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:
@@ -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(¬as);
|
||||
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(¬as);
|
||||
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(¬as);
|
||||
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(¬as);
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,3 +2,4 @@ pub mod layout;
|
||||
pub mod nota;
|
||||
pub mod resultado_analise;
|
||||
pub mod serie;
|
||||
pub mod chave_serie;
|
||||
|
||||
@@ -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 (1–3 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,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(¬as);
|
||||
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(¬as);
|
||||
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(¬as);
|
||||
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(¬as);
|
||||
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(¬as);
|
||||
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(¬as);
|
||||
// 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));
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)?,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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<_>>()
|
||||
|
||||
@@ -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
@@ -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
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user