**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.
167 lines
6.1 KiB
Rust
167 lines
6.1 KiB
Rust
use crate::domain::{
|
|
entities::{
|
|
chave_serie::ChaveSerie,
|
|
nota::Nota,
|
|
resultado_analise::{ResultadoAnalise, ResultadoPreAnalise},
|
|
},
|
|
services::{
|
|
detector_duplicidade::duplicidades_por_serie,
|
|
detector_sequencia::{LIMITE_FALTANTES, calcular_intervalo, detectar_faltantes},
|
|
},
|
|
};
|
|
use rust_decimal::Decimal;
|
|
use std::collections::HashMap;
|
|
|
|
/// Executa a pré-análise: calcula intervalos e duplicatas, sem expandir faltantes.
|
|
/// 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 (serie, documento_tipo)
|
|
let mut por_chave: HashMap<ChaveSerie, Vec<&Nota>> = HashMap::new();
|
|
for nota in notas {
|
|
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<ChaveSerie, Decimal> = HashMap::new();
|
|
let mut total_por_serie: HashMap<ChaveSerie, usize> = HashMap::new();
|
|
|
|
for (chave, notas_grupo) in &por_chave {
|
|
// Somar valores
|
|
for nota in notas_grupo.iter() {
|
|
if let Some(v) = nota.valor {
|
|
soma_total += v;
|
|
*soma_por_serie.entry(chave.clone()).or_insert(Decimal::ZERO) += v;
|
|
}
|
|
}
|
|
*total_por_serie.entry(chave.clone()).or_insert(0) += notas_grupo.len();
|
|
|
|
// Calcular intervalo de faltantes
|
|
if let Some(intervalo) = calcular_intervalo(notas_grupo) {
|
|
intervalos_por_serie.insert(chave.clone(), intervalo);
|
|
}
|
|
}
|
|
|
|
let duplicadas_por_serie = duplicidades_por_serie(notas);
|
|
|
|
ResultadoPreAnalise {
|
|
intervalos_por_serie,
|
|
duplicadas_por_serie,
|
|
soma_total,
|
|
soma_por_serie,
|
|
total_por_serie,
|
|
}
|
|
}
|
|
|
|
/// 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(|(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_chave: HashMap<ChaveSerie, Vec<&Nota>> = HashMap::new();
|
|
for nota in notas {
|
|
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 (chave, notas_grupo) in &por_chave {
|
|
let faltantes = detectar_faltantes(notas_grupo);
|
|
faltantes_por_serie.insert(chave.clone(), faltantes);
|
|
}
|
|
|
|
ResultadoAnalise {
|
|
faltantes_por_serie,
|
|
duplicadas_por_serie: pre.duplicadas_por_serie,
|
|
soma_total: pre.soma_total,
|
|
soma_por_serie: pre.soma_por_serie,
|
|
total_por_serie: pre.total_por_serie,
|
|
}
|
|
}
|
|
|
|
#[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(), 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]
|
|
fn analise_simples() {
|
|
let notas = vec![
|
|
nota(1, "001", Some(dec!(100.00))),
|
|
nota(2, "001", Some(dec!(200.00))),
|
|
nota(4, "001", Some(dec!(50.00))),
|
|
];
|
|
let resultado = executar_analise(¬as);
|
|
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));
|
|
}
|
|
|
|
#[test]
|
|
fn analise_multiplas_series() {
|
|
let notas = vec![
|
|
nota(1, "001", None),
|
|
nota(3, "001", None),
|
|
nota(1, "002", None),
|
|
nota(2, "002", None),
|
|
];
|
|
let resultado = executar_analise(¬as);
|
|
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]
|
|
fn pre_analise_detecta_intervalo_excessivo() {
|
|
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);
|
|
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]);
|
|
}
|
|
}
|