Compare commits

..

2 Commits

Author SHA1 Message Date
7c0a61383a Fix batch count showing 0 in generate command output
- Save original batch count before processing loop
- Use saved count in final output message
- Fixes issue #3: 'across 0 batches' bug
2026-04-02 12:50:54 +02:00
e9cf2e031f 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
2026-04-02 12:34:14 +02:00
2 changed files with 241 additions and 57 deletions

View File

@@ -1,6 +1,6 @@
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use csv::ReaderBuilder; use csv::ReaderBuilder;
use std::collections::BTreeMap; use std::collections::{BTreeMap, HashMap};
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
@@ -101,6 +101,49 @@ pub fn read_csv_file(path: &Path) -> anyhow::Result<Batch> {
}) })
} }
/// 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<HashMap<String, Batch>> {
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<String, Vec<Transaction>> = 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<String, Batch> = 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<String, Customer> { pub fn group_by_customer(batches: &[Batch]) -> BTreeMap<String, Customer> {
let mut customers: BTreeMap<String, Customer> = BTreeMap::new(); let mut customers: BTreeMap<String, Customer> = BTreeMap::new();
@@ -126,3 +169,132 @@ pub fn group_by_customer(batches: &[Batch]) -> BTreeMap<String, Customer> {
customers 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"));
}
}

View File

@@ -8,7 +8,7 @@ use chrono::{NaiveDateTime, Utc};
use config::{Config, Env}; use config::{Config, Env};
use csv::ReaderBuilder; use csv::ReaderBuilder;
use db::{create_pool, Repository}; 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::collections::HashMap;
use std::env; use std::env;
use std::fs; use std::fs;
@@ -330,28 +330,36 @@ async fn main() -> anyhow::Result<()> {
let temp_cleaned_path = let temp_cleaned_path =
base_output_dir.join(format!("{}.temp.csv", filename.trim_end_matches(".txt"))); 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 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); let output_dir = base_output_dir.join(&batch_number);
fs::create_dir_all(&output_dir)?; fs::create_dir_all(&output_dir)?;
fs::copy(input_path, output_dir.join(format!("{}.txt", batch_number)))?; let csv_path = output_dir.join(format!("{}.csv", batch_number));
fs::rename( let txt_path = output_dir.join(format!("{}.txt", batch_number));
&temp_cleaned_path,
output_dir.join(format!("{}.csv", batch_number)), fs::copy(&temp_cleaned_path, &csv_path)?;
)?; fs::copy(input_path, &txt_path)?;
println!( println!(
"Converted {} transactions", "Batch {}: {} transactions",
fs::read_to_string(output_dir.join(format!("{}.csv", batch_number)))? batch_number,
.lines() batch.transactions.len()
.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 first_date = batch.transactions.first().map(|t| t.date).unwrap();
let last_date = batch.transactions.last().map(|t| t.date).unwrap(); let last_date = batch.transactions.last().map(|t| t.date).unwrap();
let period = format!( let period = format!(
@@ -375,9 +383,6 @@ async fn main() -> anyhow::Result<()> {
.unwrap(); .unwrap();
fs::write(output_dir.join("index.html"), html)?; fs::write(output_dir.join("index.html"), html)?;
let generated_date = Utc::now().format("%Y-%m-%d %H:%M").to_string();
let customer_count = customers.len();
for (customer_num, customer) in customers { for (customer_num, customer) in customers {
let prepared = PreparedCustomer::from_customer(customer); let prepared = PreparedCustomer::from_customer(customer);
let customer_html = CustomerTemplate { let customer_html = CustomerTemplate {
@@ -388,14 +393,21 @@ async fn main() -> anyhow::Result<()> {
} }
.render() .render()
.unwrap(); .unwrap();
let filename = format!("customer_{}.html", customer_num); let customer_filename = format!("customer_{}.html", customer_num);
fs::write(output_dir.join(&filename), customer_html)?; fs::write(output_dir.join(&customer_filename), customer_html)?;
println!("Generated {}", filename); println!(" Generated {}", customer_filename);
} }
total_customers += index_customers.len();
}
fs::remove_file(temp_cleaned_path)?;
println!( println!(
"\nGenerated {} customer invoices in {:?}", "\nGenerated {} customer invoices across {} batches in {:?}",
customer_count, output_dir total_customers,
batch_count,
base_output_dir
); );
} }
"db" => { "db" => {