Add MariaDB database support for storing transaction data

Introduces a new database layer to persist CSV transaction data in MariaDB,
enabling both invoicing and sales reporting queries. This replaces the
previous file-to-file-only processing.

Changes:
- Add sqlx, tokio, toml, anyhow, bigdecimal dependencies to Cargo.toml
- Create config module for TOML-based configuration (database credentials)
- Create db module with connection pool, models, and repository
- Create commands module with 'import' subcommand for CSV ingestion
- Refactor main.rs to use subcommand architecture (import/generate)
- Add migration SQL file for manual database schema creation

Schema (3 tables):
- customers: customer_number, card_report_group (1=fleet, 3/4=retail)
- cards: card_number, card_type, customer_id (nullable for anonymous)
- transactions: full transaction data with FK to cards/customers

Usage:
  cargo run -- import <csv-file>   # Import to database
  cargo run -- generate <csv> <dir>  # Generate HTML invoices (unchanged)

Configuration:
  cp config.example.toml config.toml  # Edit with database credentials
  mysql < migrations/001_initial_schema.sql  # Create database first
This commit is contained in:
2026-04-02 06:33:38 +02:00
parent 39b62014b0
commit 9daa186ff6
13 changed files with 762 additions and 93 deletions
+137 -90
View File
@@ -1,14 +1,18 @@
mod commands;
mod config;
mod db;
mod invoice_generator;
use askama::Template;
use chrono::{NaiveDateTime, Utc};
use config::Config;
use csv::ReaderBuilder;
use db::{create_pool, Repository};
use invoice_generator::{group_by_customer, read_csv_file, Customer};
use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::Path;
mod invoice_generator;
use invoice_generator::{group_by_customer, read_csv_file, Customer};
use std::path::{Path, PathBuf};
fn fmt(v: f64) -> String {
format!("{:.2}", v)
@@ -17,7 +21,7 @@ fn fmt(v: f64) -> String {
fn clean_csv_file(
input_path: &Path,
output_path: &Path,
) -> Result<String, Box<dyn std::error::Error>> {
) -> anyhow::Result<String> {
let file = fs::File::open(input_path)?;
let mut rdr = ReaderBuilder::new()
.delimiter(b'\t')
@@ -229,99 +233,142 @@ struct CustomerTemplate {
generated_date: String,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args: Vec<String> = env::args().collect();
if args.len() != 3 {
eprintln!("Användning: {} <csv-fil> <utdatakatalog>", args[0]);
if args.len() < 2 {
print_usage(&args[0]);
std::process::exit(1);
}
let input_path = Path::new(&args[1]);
let base_output_dir = Path::new(&args[2]);
match args[1].as_str() {
"import" => {
if args.len() != 3 {
eprintln!("Användning: {} import <csv-fil>", args[0]);
std::process::exit(1);
}
let csv_path = PathBuf::from(&args[2]);
if !csv_path.exists() {
eprintln!("Fel: Filen hittades inte: {:?}", csv_path);
std::process::exit(1);
}
if !input_path.exists() {
eprintln!("Fel: Filen hittades inte: {:?}", input_path);
std::process::exit(1);
}
let config = Config::load()?;
let pool = create_pool(&config.database.connection_url()).await?;
let repo = Repository::new(pool);
let filename = input_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
println!("Konverterar {} till rensat format...", filename);
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)?;
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!(
"Konverterade {} transaktioner",
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!("Laddade {} transaktioner", 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 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(),
period: period.clone(),
generated_date: generated_date.clone(),
commands::run_import(&csv_path, &repo).await?;
}
.render()
.unwrap();
let filename = format!("customer_{}.html", customer_num);
fs::write(output_dir.join(&filename), customer_html)?;
println!("Genererade {}", filename);
}
"generate" => {
if args.len() != 3 {
eprintln!("Användning: {} generate <csv-fil> <utdatakatalog>", args[0]);
std::process::exit(1);
}
let input_path = Path::new(&args[2]);
let base_output_dir = Path::new(&args[3]);
println!(
"\nGenererade {} kundfakturor i {:?}",
customer_count, output_dir
);
if !input_path.exists() {
eprintln!("Fel: Filen hittades inte: {:?}", input_path);
std::process::exit(1);
}
let filename = input_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
println!("Konverterar {} till rensat format...", filename);
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)?;
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!(
"Konverterade {} transaktioner",
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!("Laddade {} transaktioner", 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 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(),
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!("Genererade {}", filename);
}
println!(
"\nGenererade {} kundfakturor i {:?}",
customer_count, output_dir
);
}
"help" | "--help" | "-h" => {
print_usage(&args[0]);
}
_ => {
eprintln!("Okänt kommando: {}", args[1]);
print_usage(&args[0]);
std::process::exit(1);
}
}
Ok(())
}
fn print_usage(program: &str) {
eprintln!("Användning: {} <kommando> [argument]", program);
eprintln!();
eprintln!("Kommandon:");
eprintln!(" import <csv-fil> Importera CSV-data till databasen");
eprintln!(" generate <csv-fil> <dir> Generera HTML-fakturor från CSV");
eprintln!(" help Visa denna hjälptext");
}