From e9cf2e031f3c3cb6ecc1b72be81532e389cd58be Mon Sep 17 00:00:00 2001 From: Jakob Date: Thu, 2 Apr 2026 12:34:14 +0200 Subject: [PATCH] Implement multi-batch invoice generation - Add read_csv_file_by_batch() to group transactions by batch_number - Modify generate command to create separate directories per batch - Each batch directory contains index.html and customer_XXX.html files - Add 3 unit tests for batch grouping logic - Fixes issue #1: CSV with multiple batches now generates separate invoices Closes: #1 --- src/invoice_generator.rs | 174 ++++++++++++++++++++++++++++++++++++++- src/main.rs | 123 ++++++++++++++------------- 2 files changed, 240 insertions(+), 57 deletions(-) diff --git a/src/invoice_generator.rs b/src/invoice_generator.rs index fd33e74..f6e4a42 100644 --- a/src/invoice_generator.rs +++ b/src/invoice_generator.rs @@ -1,6 +1,6 @@ use chrono::NaiveDateTime; use csv::ReaderBuilder; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use std::fs; use std::path::Path; @@ -101,6 +101,49 @@ pub fn read_csv_file(path: &Path) -> anyhow::Result { }) } +/// Reads a CSV file and groups transactions by batch number. +/// +/// AI AGENT NOTE: Returns a HashMap where keys are batch_numbers and values +/// are Batches containing only transactions for that batch. This enables +/// generating separate invoices per batch when a single CSV contains multiple. +pub fn read_csv_file_by_batch(path: &Path) -> anyhow::Result> { + let file = fs::File::open(path)?; + let mut rdr = ReaderBuilder::new() + .delimiter(b'\t') + .has_headers(true) + .flexible(true) + .from_reader(file); + + let mut batches: HashMap> = HashMap::new(); + + for result in rdr.records() { + let record = result?; + if let Some(tx) = Transaction::from_record(&record) { + if tx.amount > 0.0 && !tx.customer_number.is_empty() { + batches.entry(tx.batch_number.clone()).or_default().push(tx); + } + } + } + + let mut result: HashMap = HashMap::new(); + for (batch_number, mut transactions) in batches { + transactions.sort_by(|a, b| a.date.cmp(&b.date)); + result.insert( + batch_number, + Batch { + filename: path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string(), + transactions, + }, + ); + } + + Ok(result) +} + pub fn group_by_customer(batches: &[Batch]) -> BTreeMap { let mut customers: BTreeMap = BTreeMap::new(); @@ -126,3 +169,132 @@ pub fn group_by_customer(batches: &[Batch]) -> BTreeMap { customers } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_transaction_from_record_extracts_batch_number() { + let csv_content = "2026-02-01 10:15:16\t409\t559.26\t35.85\t15.60\t1001\t95 Oktan\t7825017523017000642\ttype\t1861\t97254\t1\t2\t000910\t1\t"; + + let mut rdr = csv::ReaderBuilder::new() + .delimiter(b'\t') + .has_headers(false) + .from_reader(csv_content.as_bytes()); + + let record = rdr.records().next().unwrap().unwrap(); + let tx = Transaction::from_record(&record).unwrap(); + + assert_eq!(tx.batch_number, "409"); + assert_eq!(tx.customer_number, "1861"); + } + + #[test] + fn test_group_by_customer_with_multiple_batches() { + let batch1 = Batch { + filename: "test.csv".to_string(), + transactions: vec![ + Transaction { + date: NaiveDateTime::parse_from_str("2026-02-01 10:00:00", "%Y-%m-%d %H:%M:%S") + .unwrap(), + batch_number: "409".to_string(), + amount: 100.0, + volume: 10.0, + price: 10.0, + quality: 1001, + quality_name: "95 Oktan".to_string(), + card_number: "CARD001".to_string(), + card_type: "type".to_string(), + customer_number: "CUST1".to_string(), + station: "S1".to_string(), + terminal: "T1".to_string(), + pump: "P1".to_string(), + receipt: "R1".to_string(), + card_report_group_number: "1".to_string(), + control_number: "".to_string(), + }, + Transaction { + date: NaiveDateTime::parse_from_str("2026-02-01 11:00:00", "%Y-%m-%d %H:%M:%S") + .unwrap(), + batch_number: "410".to_string(), + amount: 200.0, + volume: 20.0, + price: 10.0, + quality: 1001, + quality_name: "95 Oktan".to_string(), + card_number: "CARD002".to_string(), + card_type: "type".to_string(), + customer_number: "CUST1".to_string(), + station: "S1".to_string(), + terminal: "T1".to_string(), + pump: "P1".to_string(), + receipt: "R2".to_string(), + card_report_group_number: "1".to_string(), + control_number: "".to_string(), + }, + ], + }; + + let customers = group_by_customer(&[batch1]); + + // Should have 1 customer with 2 cards + assert_eq!(customers.len(), 1); + assert!(customers.contains_key("CUST1")); + assert_eq!(customers.get("CUST1").unwrap().cards.len(), 2); + } + + #[test] + fn test_group_by_customer_separate_customers() { + let batch1 = Batch { + filename: "test.csv".to_string(), + transactions: vec![ + Transaction { + date: NaiveDateTime::parse_from_str("2026-02-01 10:00:00", "%Y-%m-%d %H:%M:%S") + .unwrap(), + batch_number: "409".to_string(), + amount: 100.0, + volume: 10.0, + price: 10.0, + quality: 1001, + quality_name: "95 Oktan".to_string(), + card_number: "CARD001".to_string(), + card_type: "type".to_string(), + customer_number: "CUST1".to_string(), + station: "S1".to_string(), + terminal: "T1".to_string(), + pump: "P1".to_string(), + receipt: "R1".to_string(), + card_report_group_number: "1".to_string(), + control_number: "".to_string(), + }, + Transaction { + date: NaiveDateTime::parse_from_str("2026-02-01 11:00:00", "%Y-%m-%d %H:%M:%S") + .unwrap(), + batch_number: "409".to_string(), + amount: 200.0, + volume: 20.0, + price: 10.0, + quality: 1001, + quality_name: "95 Oktan".to_string(), + card_number: "CARD003".to_string(), + card_type: "type".to_string(), + customer_number: "CUST2".to_string(), + station: "S1".to_string(), + terminal: "T1".to_string(), + pump: "P1".to_string(), + receipt: "R2".to_string(), + card_report_group_number: "1".to_string(), + control_number: "".to_string(), + }, + ], + }; + + let customers = group_by_customer(&[batch1]); + + // Should have 2 customers + assert_eq!(customers.len(), 2); + assert!(customers.contains_key("CUST1")); + assert!(customers.contains_key("CUST2")); + } +} diff --git a/src/main.rs b/src/main.rs index 2e164f5..4ee3b4e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,7 @@ 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, Customer}; +use invoice_generator::{group_by_customer, read_csv_file_by_batch, Customer}; use std::collections::HashMap; use std::env; use std::fs; @@ -330,72 +330,83 @@ async fn main() -> anyhow::Result<()> { let temp_cleaned_path = base_output_dir.join(format!("{}.temp.csv", filename.trim_end_matches(".txt"))); - let batch_number = clean_csv_file(input_path, &temp_cleaned_path)?; + clean_csv_file(input_path, &temp_cleaned_path)?; - let output_dir = base_output_dir.join(&batch_number); - fs::create_dir_all(&output_dir)?; - - fs::copy(input_path, output_dir.join(format!("{}.txt", batch_number)))?; - fs::rename( - &temp_cleaned_path, - output_dir.join(format!("{}.csv", batch_number)), - )?; - - println!( - "Converted {} transactions", - fs::read_to_string(output_dir.join(format!("{}.csv", batch_number)))? - .lines() - .count() - - 1 - ); - - let batch = read_csv_file(&output_dir.join(format!("{}.csv", batch_number)))?; - println!("Loaded {} transactions", 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)?; + let mut batches = read_csv_file_by_batch(&temp_cleaned_path)?; + println!("Found {} batches in CSV", batches.len()); + let mut total_customers = 0usize; let generated_date = Utc::now().format("%Y-%m-%d %H:%M").to_string(); - let customer_count = customers.len(); - for (customer_num, customer) in customers { - let prepared = PreparedCustomer::from_customer(customer); - let customer_html = CustomerTemplate { - customer: prepared, - batch_number: batch_number.clone(), + 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(), - generated_date: generated_date.clone(), } .render() .unwrap(); - let filename = format!("customer_{}.html", customer_num); - fs::write(output_dir.join(&filename), customer_html)?; - println!("Generated {}", filename); + 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 in {:?}", - customer_count, output_dir + "\nGenerated {} customer invoices across {} batches in {:?}", + total_customers, + batches.len(), + base_output_dir ); } "db" => {