Files
rusty-petroleum/src/main.rs
T
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

469 lines
16 KiB
Rust

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<String> {
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<ProductSummary>,
avrundningsfel: String,
oresutjamning: String,
}
#[derive(Clone)]
struct CardData {
card_number: String,
transactions: Vec<FormattedTransaction>,
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<CardData>,
summary: Summary,
}
impl PreparedCustomer {
fn from_customer(customer: Customer) -> Self {
let cards: Vec<CardData> = customer
.cards
.into_iter()
.map(|(card_number, transactions)| {
let formatted_txs: Vec<FormattedTransaction> = 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::<f64>().unwrap())
.sum();
let total_volume: f64 = formatted_txs
.iter()
.map(|t| t.volume.parse::<f64>().unwrap())
.sum();
CardData {
card_number,
transactions: formatted_txs,
total_amount: fmt(total_amount),
total_volume: fmt(total_volume),
}
})
.collect();
let mut product_totals: HashMap<String, (f64, f64)> = 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::<f64>().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<ProductSummary> = 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::<f64>().unwrap())
.sum();
let grand_total: f64 = products
.iter()
.map(|p| p.amount.parse::<f64>().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<String> {
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<String> = 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 <csv-file> [--env <name>]", 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 <csv-file> <output-dir> [--env <name>]", 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 <subcommand> [--env <name>]", 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: {} <command> [arguments]", program);
eprintln!();
eprintln!("Commands:");
eprintln!(" import <csv-file> [--env <name>] Import CSV data to database (default: prod)");
eprintln!(" generate <csv> <dir> Generate HTML invoices from CSV");
eprintln!(" db setup [--env <name>] Create database and schema (default: prod)");
eprintln!(" db reset [--env <name>] Drop and recreate database (default: prod)");
eprintln!(" help Show this help message");
eprintln!();
eprintln!("Environments: prod (default), dev, test");
}