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:
+137
-90
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user