mod commands; mod config; mod db; mod invoice_generator; use askama::Template; use chrono::{NaiveDateTime, Utc}; use config::{Config, Env}; use csv::ReaderBuilder; use db::{create_pool, Repository}; use invoice_generator::{group_by_customer, read_csv_file_by_batch, Customer}; use std::collections::HashMap; use std::env; use std::fs; use std::path::{Path, PathBuf}; fn fmt(v: f64) -> String { format!("{:.2}", v) } /// Normalizes CSV date format and cleans the data. /// /// AI AGENT NOTE: Input CSV may have dates in different formats (MM/DD/YYYY or YYYY-MM-DD). /// This function standardizes to YYYY-MM-DD HH:MM:SS format for consistent parsing. fn clean_csv_file( input_path: &Path, output_path: &Path, ) -> anyhow::Result { let file = fs::File::open(input_path)?; let mut rdr = ReaderBuilder::new() .delimiter(b'\t') .has_headers(true) .flexible(true) .from_reader(file); let output = fs::File::create(output_path)?; let mut writer = csv::WriterBuilder::new() .delimiter(b'\t') .from_writer(output); let mut batch_number = String::new(); for result in rdr.records() { let record = result?; let date_str = record.get(0).unwrap_or(""); let batch = record.get(1).unwrap_or("").to_string(); if batch_number.is_empty() { batch_number = batch.clone(); } let date = NaiveDateTime::parse_from_str(date_str, "%m/%d/%Y %I:%M:%S %p").unwrap_or_else(|_| { NaiveDateTime::parse_from_str(date_str, "%Y-%m-%d %H:%M:%S").unwrap_or_default() }); let row = vec![ date.format("%Y-%m-%d %H:%M:%S").to_string(), batch, record.get(2).unwrap_or("").to_string(), record.get(3).unwrap_or("").to_string(), record.get(4).unwrap_or("").to_string(), record.get(5).unwrap_or("").to_string(), record.get(6).unwrap_or("").to_string(), record.get(7).unwrap_or("").to_string(), record.get(8).unwrap_or("").to_string(), record.get(9).unwrap_or("").to_string(), record.get(10).unwrap_or("").to_string(), record.get(11).unwrap_or("").to_string(), record.get(12).unwrap_or("").to_string(), record.get(13).unwrap_or("").to_string(), record.get(14).unwrap_or("").to_string(), record.get(15).unwrap_or("").to_string(), ]; writer.write_record(&row)?; } writer.flush()?; Ok(batch_number) } #[derive(Clone)] struct ProductSummary { name: String, volume: String, avg_price: String, amount: String, } #[derive(Clone)] struct Summary { total_volume: String, grand_total: String, products: Vec, avrundningsfel: String, oresutjamning: String, } #[derive(Clone)] struct CardData { card_number: String, transactions: Vec, total_amount: String, total_volume: String, } #[derive(Clone)] struct FormattedTransaction { date: String, quality_name: String, price: String, volume: String, amount: String, receipt: String, } #[derive(Clone)] struct PreparedCustomer { customer_number: String, cards: Vec, summary: Summary, } impl PreparedCustomer { fn from_customer(customer: Customer) -> Self { let cards: Vec = customer .cards .into_iter() .map(|(card_number, transactions)| { let formatted_txs: Vec = transactions .into_iter() .map(|t| FormattedTransaction { date: t.date.format("%Y-%m-%d %H:%M").to_string(), quality_name: t.quality_name, price: fmt(t.price * 0.8), volume: fmt(t.volume), amount: fmt(t.amount * 0.8), receipt: t.receipt, }) .collect(); let total_amount: f64 = formatted_txs .iter() .map(|t| t.amount.parse::().unwrap()) .sum(); let total_volume: f64 = formatted_txs .iter() .map(|t| t.volume.parse::().unwrap()) .sum(); CardData { card_number, transactions: formatted_txs, total_amount: fmt(total_amount), total_volume: fmt(total_volume), } }) .collect(); let mut product_totals: HashMap = HashMap::new(); let mut total_from_transactions: f64 = 0.0; for card in &cards { for tx in &card.transactions { let volume: f64 = tx.volume.parse().unwrap(); let amount: f64 = tx.amount.parse::().unwrap(); total_from_transactions += amount; let entry = product_totals .entry(tx.quality_name.clone()) .or_insert((0.0, 0.0)); entry.0 += volume; entry.1 += amount; } } let mut products: Vec = product_totals .into_iter() .map(|(name, (volume, amount))| { let avg_price = if volume > 0.0 { amount / volume } else { 0.0 }; let rounded_volume = (volume * 100.0).round() / 100.0; let rounded_avg_price = (avg_price * 100.0).round() / 100.0; let calculated_amount = rounded_volume * rounded_avg_price; ProductSummary { name, volume: fmt(rounded_volume), avg_price: fmt(rounded_avg_price), amount: fmt(calculated_amount), } }) .collect(); products.sort_by(|a, b| a.name.cmp(&b.name)); let total_volume: f64 = products .iter() .map(|p| p.volume.parse::().unwrap()) .sum(); let grand_total: f64 = products .iter() .map(|p| p.amount.parse::().unwrap()) .sum(); let avrundningsfel = total_from_transactions - grand_total; let rounded_grand_total = total_from_transactions.round(); let oresutjamning = rounded_grand_total - grand_total - avrundningsfel; let summary = Summary { total_volume: fmt(total_volume), grand_total: fmt(rounded_grand_total), products, avrundningsfel: fmt(avrundningsfel), oresutjamning: fmt(oresutjamning), }; PreparedCustomer { customer_number: customer.customer_number, cards, summary, } } } #[derive(Template)] #[template(path = "index.html")] struct IndexTemplate { customers: Vec<(String, usize)>, period: String, } #[derive(Template)] #[template(path = "customer.html")] struct CustomerTemplate { customer: PreparedCustomer, batch_number: String, period: String, generated_date: String, } /// Parses the --env flag from CLI arguments. /// /// AI AGENT NOTE: The --env flag can appear anywhere in the argument list. /// Returns the environment and the index of the "--env" flag (for removal). /// Defaults to Prod if not specified. fn parse_env_flag(args: &[String]) -> (Env, usize) { for (i, arg) in args.iter().enumerate() { if arg == "--env" && i + 1 < args.len() { match args[i + 1].parse() { Ok(env) => return (env, i), Err(e) => { eprintln!("Error: {}", e); std::process::exit(1); } } } } (Env::default(), 0) } /// Removes --env and its value from argument list. /// /// AI AGENT NOTE: This allows the --env flag to appear anywhere in the /// command without affecting positional argument parsing. fn remove_env_flags(args: &[String]) -> Vec { let (_, env_idx) = parse_env_flag(args); let mut result = Vec::with_capacity(args.len()); for (i, arg) in args.iter().enumerate() { if i == env_idx || (i == env_idx + 1 && args.get(env_idx) == Some(&"--env".to_string())) { continue; } result.push(arg.clone()); } result } #[tokio::main] async fn main() -> anyhow::Result<()> { let args: Vec = env::args().collect(); let (env, _) = parse_env_flag(&args); if args.len() < 2 { print_usage(&args[0]); std::process::exit(1); } match args[1].as_str() { "import" => { let clean_args = remove_env_flags(&args); if clean_args.len() != 3 { eprintln!("Usage: {} import [--env ]", clean_args[0]); std::process::exit(1); } let csv_path = PathBuf::from(&clean_args[2]); if !csv_path.exists() { eprintln!("Error: File not found: {:?}", csv_path); std::process::exit(1); } println!("Environment: {}", env.as_str()); let config = Config::load(env)?; let pool = create_pool(&config.database.connection_url()).await?; let repo = Repository::new(pool); commands::run_import(&csv_path, &repo).await?; } "generate" => { let clean_args = remove_env_flags(&args); if clean_args.len() != 4 { eprintln!("Usage: {} generate [--env ]", clean_args[0]); std::process::exit(1); } let input_path = Path::new(&clean_args[2]); let base_output_dir = Path::new(&clean_args[3]); if !input_path.exists() { eprintln!("Error: File not found: {:?}", input_path); std::process::exit(1); } let filename = input_path .file_name() .and_then(|n| n.to_str()) .unwrap_or("unknown") .to_string(); println!("Converting {} to cleaned format...", filename); let temp_cleaned_path = base_output_dir.join(format!("{}.temp.csv", filename.trim_end_matches(".txt"))); clean_csv_file(input_path, &temp_cleaned_path)?; let mut batches = read_csv_file_by_batch(&temp_cleaned_path)?; let batch_count = batches.len(); println!("Found {} batches in CSV", batch_count); let mut total_customers = 0usize; let generated_date = Utc::now().format("%Y-%m-%d %H:%M").to_string(); let mut batch_numbers: Vec<_> = batches.keys().cloned().collect(); batch_numbers.sort(); for batch_number in batch_numbers { let batch = batches.remove(&batch_number).unwrap(); let output_dir = base_output_dir.join(&batch_number); fs::create_dir_all(&output_dir)?; let csv_path = output_dir.join(format!("{}.csv", batch_number)); let txt_path = output_dir.join(format!("{}.txt", batch_number)); fs::copy(&temp_cleaned_path, &csv_path)?; fs::copy(input_path, &txt_path)?; println!( "Batch {}: {} transactions", batch_number, batch.transactions.len() ); let first_date = batch.transactions.first().map(|t| t.date).unwrap(); let last_date = batch.transactions.last().map(|t| t.date).unwrap(); let period = format!( "{} - {}", first_date.format("%Y-%m-%d"), last_date.format("%Y-%m-%d") ); let customers = group_by_customer(&[batch]); let index_customers: Vec<(String, usize)> = customers .iter() .map(|(num, c)| (num.clone(), c.cards.len())) .collect(); let html = IndexTemplate { customers: index_customers.clone(), period: period.clone(), } .render() .unwrap(); fs::write(output_dir.join("index.html"), html)?; for (customer_num, customer) in customers { let prepared = PreparedCustomer::from_customer(customer); let customer_html = CustomerTemplate { customer: prepared, batch_number: batch_number.clone(), period: period.clone(), generated_date: generated_date.clone(), } .render() .unwrap(); let customer_filename = format!("customer_{}.html", customer_num); fs::write(output_dir.join(&customer_filename), customer_html)?; println!(" Generated {}", customer_filename); } total_customers += index_customers.len(); } fs::remove_file(temp_cleaned_path)?; println!( "\nGenerated {} customer invoices across {} batches in {:?}", total_customers, batch_count, base_output_dir ); } "db" => { let clean_args = remove_env_flags(&args); if clean_args.len() < 3 { eprintln!("Usage: {} db [--env ]", clean_args[0]); eprintln!("Subcommands:"); eprintln!(" setup Create database and schema"); eprintln!(" reset Drop and recreate database"); std::process::exit(1); } println!("Environment: {}", env.as_str()); let config = Config::load(env)?; match clean_args[2].as_str() { "setup" => { let pool = create_pool(&config.database.connection_url()).await?; let repo = Repository::new(pool); commands::run_db_setup(&repo, &config).await?; } "reset" => { commands::run_db_reset(&config).await?; } _ => { eprintln!("Unknown db subcommand: {}", clean_args[2]); eprintln!("Subcommands:"); eprintln!(" setup Create database and schema"); eprintln!(" reset Drop and recreate database"); std::process::exit(1); } } } "help" | "--help" | "-h" => { print_usage(&args[0]); } _ => { eprintln!("Unknown command: {}", args[1]); print_usage(&args[0]); std::process::exit(1); } } Ok(()) } fn print_usage(program: &str) { eprintln!("Usage: {} [arguments]", program); eprintln!(); eprintln!("Commands:"); eprintln!(" import [--env ] Import CSV data to database (default: prod)"); eprintln!(" generate Generate HTML invoices from CSV"); eprintln!(" db setup [--env ] Create database and schema (default: prod)"); eprintln!(" db reset [--env ] Drop and recreate database (default: prod)"); eprintln!(" help Show this help message"); eprintln!(); eprintln!("Environments: prod (default), dev, test"); }