Skip to main content

cfdi_cli/
bulk.rs

1use std::fs;
2use std::io::{self, Write};
3use std::path::Path;
4
5use colored::Colorize;
6
7use crate::formatter::{format_csv_header, format_csv_row, format_table, OutputFormat};
8use crate::models::Comprobante;
9use crate::parser;
10
11pub struct BulkResult {
12    pub total: usize,
13    pub success: usize,
14    pub failed: usize,
15    pub errors: Vec<(String, String)>,
16}
17
18pub fn process_directory(
19    dir: &Path,
20    format: OutputFormat,
21    output: Option<&Path>,
22) -> io::Result<BulkResult> {
23    let xml_files = collect_xml_files(dir)?;
24
25    let mut result = BulkResult {
26        total: xml_files.len(),
27        success: 0,
28        failed: 0,
29        errors: Vec::new(),
30    };
31
32    let cfdis: Vec<(String, Comprobante)> = xml_files
33        .iter()
34        .filter_map(|path| {
35            let filename = path.display().to_string();
36            match parser::parse_file(path) {
37                Ok(cfdi) => {
38                    result.success += 1;
39                    Some((filename, cfdi))
40                }
41                Err(e) => {
42                    result.failed += 1;
43                    result.errors.push((filename, e.to_string()));
44                    None
45                }
46            }
47        })
48        .collect();
49
50    let mut writer: Box<dyn Write> = match output {
51        Some(path) => Box::new(fs::File::create(path)?),
52        None => Box::new(io::stdout().lock()),
53    };
54
55    match format {
56        OutputFormat::Json => {
57            let all: Vec<&Comprobante> = cfdis.iter().map(|(_, c)| c).collect();
58            let json = serde_json::to_string_pretty(&all)
59                .map_err(io::Error::other)?;
60            writeln!(writer, "{json}")?;
61        }
62        OutputFormat::Csv => {
63            let mut csv_wtr = csv::Writer::from_writer(writer);
64            format_csv_header(&mut csv_wtr)?;
65            for (_, cfdi) in &cfdis {
66                format_csv_row(cfdi, &mut csv_wtr)?;
67            }
68            csv_wtr.flush()?;
69        }
70        OutputFormat::Table => {
71            for (filename, cfdi) in &cfdis {
72                writeln!(writer, "  {} {}", "Archivo:".bold(), filename)?;
73                format_table(cfdi, &mut writer)?;
74                writeln!(writer)?;
75            }
76        }
77    }
78
79    Ok(result)
80}
81
82pub fn summary(dir: &Path) -> io::Result<()> {
83    let xml_files = collect_xml_files(dir)?;
84    let mut total_ingresos: f64 = 0.0;
85    let mut total_egresos: f64 = 0.0;
86    let mut total_iva: f64 = 0.0;
87    let mut count_by_type: std::collections::HashMap<String, usize> =
88        std::collections::HashMap::new();
89    let mut by_rfc_emisor: std::collections::HashMap<String, f64> =
90        std::collections::HashMap::new();
91    let mut success = 0usize;
92    let mut failed = 0usize;
93
94    for path in &xml_files {
95        match parser::parse_file(path) {
96            Ok(cfdi) => {
97                success += 1;
98                let total: f64 = cfdi.total.parse().unwrap_or(0.0);
99                let tipo = cfdi.tipo_de_comprobante.clone();
100
101                *count_by_type.entry(tipo.clone()).or_insert(0) += 1;
102
103                match tipo.as_str() {
104                    "I" => total_ingresos += total,
105                    "E" => total_egresos += total,
106                    _ => {}
107                }
108
109                if let Some(imp) = &cfdi.impuestos {
110                    if let Some(ref t) = imp.total_impuestos_trasladados {
111                        total_iva += t.parse::<f64>().unwrap_or(0.0);
112                    }
113                }
114
115                *by_rfc_emisor.entry(cfdi.emisor.rfc.clone()).or_insert(0.0) += total;
116            }
117            Err(_) => {
118                failed += 1;
119            }
120        }
121    }
122
123    let stdout = io::stdout();
124    let mut out = stdout.lock();
125
126    writeln!(out, "\n{}", "RESUMEN DE CFDIS".bold().cyan())?;
127    writeln!(out, "{}", "=".repeat(50).dimmed())?;
128    writeln!(out, "  Archivos procesados: {success}/{}", xml_files.len())?;
129    if failed > 0 {
130        writeln!(out, "  {} {failed}", "Errores:".red())?;
131    }
132    writeln!(out)?;
133
134    writeln!(out, "  {}", "Por tipo:".bold())?;
135    for (tipo, count) in &count_by_type {
136        let label = match tipo.as_str() {
137            "I" => "Ingreso",
138            "E" => "Egreso",
139            "T" => "Traslado",
140            "P" => "Pago",
141            "N" => "Nomina",
142            other => other,
143        };
144        writeln!(out, "    {label}: {count}")?;
145    }
146    writeln!(out)?;
147
148    writeln!(out, "  {}", "Totales:".bold())?;
149    writeln!(out, "    {} ${total_ingresos:.2}", "Ingresos:".green())?;
150    writeln!(out, "    {} ${total_egresos:.2}", "Egresos:".red())?;
151    writeln!(out, "    {} ${total_iva:.2}", "IVA Trasladado:".yellow())?;
152    writeln!(
153        out,
154        "    {} ${:.2}",
155        "Neto:".bold(),
156        total_ingresos - total_egresos
157    )?;
158    writeln!(out)?;
159
160    if !by_rfc_emisor.is_empty() {
161        writeln!(out, "  {}", "Por RFC Emisor:".bold())?;
162        let mut rfcs: Vec<_> = by_rfc_emisor.into_iter().collect();
163        rfcs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
164        for (rfc, total) in rfcs {
165            writeln!(out, "    {rfc}: ${total:.2}")?;
166        }
167    }
168
169    writeln!(out, "{}", "=".repeat(50).dimmed())?;
170    Ok(())
171}
172
173fn collect_xml_files(dir: &Path) -> io::Result<Vec<std::path::PathBuf>> {
174    let mut files = Vec::new();
175    if !dir.is_dir() {
176        return Err(io::Error::new(
177            io::ErrorKind::NotFound,
178            format!("{} is not a directory", dir.display()),
179        ));
180    }
181    for entry in fs::read_dir(dir)? {
182        let entry = entry?;
183        let path = entry.path();
184        if path.is_file() {
185            if let Some(ext) = path.extension() {
186                if ext.eq_ignore_ascii_case("xml") {
187                    files.push(path);
188                }
189            }
190        }
191    }
192    files.sort();
193    Ok(files)
194}