Compare commits

..

4 Commits

Author SHA1 Message Date
jakob 460bb460bb Fix: CLI generate command works with single invocation
The remove_env_flags function was incorrectly returning index 0 when no --env
flag was present, causing it to mistakenly skip the first argument
(program name) and shift all arguments incorrectly.

This required users to specify 'generate' twice to make the CLI work.

Fix by checking if --env was actually found (env_idx > 0) before removing
any arguments from the list.
2026-04-10 14:05:46 +02:00
jakob c0c43ddf20 Add AGENTS.md with project guidelines for opencode 2026-04-10 13:44:03 +02:00
jakob 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
jakob 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
3 changed files with 254 additions and 57 deletions
+10
View File
@@ -0,0 +1,10 @@
# Issue Management
- Use Gitea REST API to close issues when fixing them:
```bash
curl -X PATCH https://gitea.rowanbrook.net/api/v1/repos/jakob/rusty-petroleum/issues/{issue_number} \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{"state": "closed"}'
```
- Or suggest closing issues on Gitea web UI when automatic closing via API is not configured
+173 -1
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"));
}
}
+38 -23
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;
@@ -263,6 +263,9 @@ fn parse_env_flag(args: &[String]) -> (Env, usize) {
/// command without affecting positional argument parsing. /// command without affecting positional argument parsing.
fn remove_env_flags(args: &[String]) -> Vec<String> { fn remove_env_flags(args: &[String]) -> Vec<String> {
let (_, env_idx) = parse_env_flag(args); let (_, env_idx) = parse_env_flag(args);
if env_idx == 0 {
return args.to_vec();
}
let mut result = Vec::with_capacity(args.len()); let mut result = Vec::with_capacity(args.len());
for (i, arg) in args.iter().enumerate() { for (i, arg) in args.iter().enumerate() {
@@ -330,28 +333,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 +386,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 +396,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" => {