From 650435e00c7d230e1e3b9d901c1fb9ce3bbc2767 Mon Sep 17 00:00:00 2001 From: Jakob Date: Mon, 23 Mar 2026 10:36:14 +0100 Subject: [PATCH] Add invoice generator for fuel station transactions - Read CSV files from input/ directory - Generate static HTML invoices grouped by customer and card - Filter transactions to only include fleet customers - Compact print-friendly layout with 2 decimal precision --- Cargo.toml | 10 +++ src/invoice_generator.rs | 126 ++++++++++++++++++++++++++++ src/main.rs | 175 +++++++++++++++++++++++++++++++++++++++ src/templates.rs | 51 ++++++++++++ templates/customer.html | 160 +++++++++++++++++++++++++++++++++++ templates/index.html | 87 +++++++++++++++++++ 6 files changed, 609 insertions(+) create mode 100644 Cargo.toml create mode 100644 src/invoice_generator.rs create mode 100644 src/main.rs create mode 100644 src/templates.rs create mode 100644 templates/customer.html create mode 100644 templates/index.html diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..6c6e527 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "invoice-generator" +version = "0.1.0" +edition = "2021" + +[dependencies] +askama = "0.15.5" +chrono = "0.4.44" +csv = "1.4.0" +serde = "1.0.228" diff --git a/src/invoice_generator.rs b/src/invoice_generator.rs new file mode 100644 index 0000000..1b485cd --- /dev/null +++ b/src/invoice_generator.rs @@ -0,0 +1,126 @@ +use chrono::NaiveDateTime; +use csv::ReaderBuilder; +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; + +#[derive(Debug, Clone)] +pub struct Transaction { + pub date: NaiveDateTime, + pub batch_number: String, + pub amount: f64, + pub volume: f64, + pub price: f64, + pub quality: i32, + pub quality_name: String, + pub card_number: String, + pub card_type: String, + pub customer_number: String, + pub station: String, + pub terminal: String, + pub pump: String, + pub receipt: String, + pub card_report_group_number: String, + pub control_number: String, +} + +#[derive(Debug, Clone)] +pub struct Batch { + pub filename: String, + pub transactions: Vec, +} + +#[derive(Debug, Clone)] +pub struct Customer { + pub customer_number: String, + pub cards: BTreeMap>, +} + +fn get_field(record: &csv::StringRecord, index: usize) -> &str { + record.get(index).unwrap_or("") +} + +impl Transaction { + pub fn from_record(record: &csv::StringRecord) -> Option { + let date_str = get_field(record, 0); + let date = NaiveDateTime::parse_from_str(date_str, "%Y-%m-%d %H:%M:%S").ok()?; + + Some(Transaction { + date, + batch_number: get_field(record, 1).to_string(), + amount: get_field(record, 2).parse().unwrap_or(0.0), + volume: get_field(record, 3).parse().unwrap_or(0.0), + price: get_field(record, 4).parse().unwrap_or(0.0), + quality: get_field(record, 5).parse().unwrap_or(0), + quality_name: get_field(record, 6).to_string(), + card_number: get_field(record, 7).to_string(), + card_type: get_field(record, 8).to_string(), + customer_number: get_field(record, 9).to_string(), + station: get_field(record, 10).to_string(), + terminal: get_field(record, 11).to_string(), + pump: get_field(record, 12).to_string(), + receipt: get_field(record, 13).to_string(), + card_report_group_number: get_field(record, 14).to_string(), + control_number: get_field(record, 15).to_string(), + }) + } +} + +pub fn read_csv_file(path: &Path) -> Result> { + let filename = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string(); + + let file = fs::File::open(path)?; + let mut rdr = ReaderBuilder::new() + .delimiter(b'\t') + .has_headers(true) + .flexible(true) + .from_reader(file); + + let mut transactions = Vec::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() { + transactions.push(tx); + } + } + } + + transactions.sort_by(|a, b| a.date.cmp(&b.date)); + + Ok(Batch { + filename, + transactions, + }) +} + +pub fn group_by_customer(batches: &[Batch]) -> BTreeMap { + let mut customers: BTreeMap = BTreeMap::new(); + + for batch in batches { + for tx in &batch.transactions { + let customer = customers + .entry(tx.customer_number.clone()) + .or_insert_with(|| Customer { + customer_number: tx.customer_number.clone(), + cards: BTreeMap::new(), + }); + + let card_txs = customer.cards.entry(tx.card_number.clone()).or_default(); + card_txs.push(tx.clone()); + } + } + + for customer in customers.values_mut() { + for card_txs in customer.cards.values_mut() { + card_txs.sort_by(|a, b| a.date.cmp(&b.date)); + } + } + + customers +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..e6e8e8f --- /dev/null +++ b/src/main.rs @@ -0,0 +1,175 @@ +use askama::Template; +use chrono::Utc; +use std::fs; +use std::path::Path; + +mod invoice_generator; + +use invoice_generator::{group_by_customer, read_csv_file, Batch, Customer}; + +fn fmt(v: f64) -> String { + format!("{:.2}", v) +} + +#[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, + grand_total: String, +} + +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), + volume: fmt(t.volume), + amount: fmt(t.amount), + 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 grand_total: f64 = cards + .iter() + .map(|c| c.total_amount.parse::().unwrap()) + .sum(); + + PreparedCustomer { + customer_number: customer.customer_number, + cards, + grand_total: fmt(grand_total), + } + } +} + +#[derive(Template)] +#[template(path = "index.html")] +struct IndexTemplate { + customers: Vec<(String, usize)>, + batches: Vec, +} + +#[derive(Template)] +#[template(path = "customer.html")] +struct CustomerTemplate { + customer: PreparedCustomer, + batches: Vec, + generated_date: String, +} + +fn main() -> Result<(), Box> { + let data_dir = Path::new("input"); + let output_dir = Path::new("output"); + + if !output_dir.exists() { + fs::create_dir_all(output_dir)?; + } + + let mut batches: Vec = Vec::new(); + + for entry in fs::read_dir(data_dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) == Some("csv") { + match read_csv_file(&path) { + Ok(batch) => { + println!( + "Loaded {} transactions from {}", + batch.transactions.len(), + batch.filename + ); + batches.push(batch); + } + Err(e) => { + eprintln!("Error reading {:?}: {}", path, e); + } + } + } + } + + batches.sort_by(|a, b| a.filename.cmp(&b.filename)); + + let batch_filenames: Vec = batches.iter().map(|b| b.filename.clone()).collect(); + + let customers = group_by_customer(&batches); + + let index_customers: Vec<(String, usize)> = customers + .iter() + .map(|(num, c)| (num.clone(), c.cards.len())) + .collect(); + + let html = IndexTemplate { + customers: index_customers, + batches: batch_filenames.clone(), + } + .render() + .unwrap(); + 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 { + let prepared = PreparedCustomer::from_customer(customer); + let customer_html = CustomerTemplate { + customer: prepared, + batches: batch_filenames.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); + } + + println!( + "\nGenerated {} customer invoices in output/", + customer_count + ); + + Ok(()) +} diff --git a/src/templates.rs b/src/templates.rs new file mode 100644 index 0000000..4a2705f --- /dev/null +++ b/src/templates.rs @@ -0,0 +1,51 @@ +use askama::Template; +use askama_derive::Filter; +use chrono::Utc; +use std::collections::BTreeMap; + +use invoice_generator::{group_by_customer, read_csv_file, Customer, Transaction}; + +#[derive(Template)] +#[template(path = "index.html")] +pub struct IndexTemplate { + pub customers: Vec<(String, Customer)>, + pub batches: Vec, +} + +#[derive(Template)] +#[template(path = "customer.html")] +pub struct CustomerTemplate { + pub customer: &Customer, + pub batches: Vec, +} + +pub fn calculate_card_totals(transactions: &[Transaction]) -> (f64, f64) { + let total_amount: f64 = transactions.iter().map(|t| t.amount).sum(); + let total_volume: f64 = transactions.iter().map(|t| t.volume).sum(); + (total_amount, total_volume) +} + +pub fn calculate_grand_total(customer: &Customer) -> f64 { + customer + .cards + .values() + .flat_map(|t| t.iter()) + .map(|t| t.amount) + .sum() +} + +pub fn format_amount(amount: f64) -> String { + format!("{:.2}", amount) +} + +pub fn format_volume(volume: f64) -> String { + format!("{:.2}", volume) +} + +pub fn format_date(date: &chrono::NaiveDateTime) -> String { + date.format("%Y-%m-%d %H:%M").to_string() +} + +pub fn now_string() -> String { + Utc::now().format("%Y-%m-%d %H:%M").to_string() +} diff --git a/templates/customer.html b/templates/customer.html new file mode 100644 index 0000000..f8bcdb0 --- /dev/null +++ b/templates/customer.html @@ -0,0 +1,160 @@ + + + + + Invoice - Customer {{ customer.customer_number }} + + + +
+
+

Invoice - Customer {{ customer.customer_number }}

+
Batches: {{ batches | join(", ") }}
+
+
+
Generated: {{ generated_date }}
+
+
+ + {% for card in customer.cards %} +
+
+ Card: {{ card.card_number }} + {{ card.transactions.len() }} transactions +
+
+ Total: {{ card.total_amount }} | Volume: {{ card.total_volume }} L +
+ + + + + + + + + + + + + {% for tx in card.transactions %} + + + + + + + + + {% endfor %} + + + + + + + +
DateProductPrice/LVolume (L)AmountReceipt
{{ tx.date }}{{ tx.quality_name }}{{ tx.price }}{{ tx.volume }}{{ tx.amount }}{{ tx.receipt }}
Card Total{{ card.total_volume }}{{ card.total_amount }}
+
+ {% endfor %} + +
+ Grand Total:{{ customer.grand_total }} +
+ + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..3e51085 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,87 @@ + + + + + Invoice Overview + + + +

Invoice Overview

+ +
+ Processed Batches: {{ batches | join(", ") }} +
+ + + + + + + + + + + {% for (num, card_count) in customers %} + + + + + + {% endfor %} + +
Customer NumberCardsInvoice
{{ num }}{{ card_count }}View
+ +