Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf8628becb | |||
| 7c0a61383a | |||
| e9cf2e031f |
17
AGENTS.md
Normal file
17
AGENTS.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# rusty-petroleum Project Guidelines
|
||||
|
||||
## Git Commit Style
|
||||
- Make small, focused commits while working
|
||||
- Commit changes incrementally to make history easier to follow
|
||||
- Each commit should represent a single logical change
|
||||
- Write clear, concise commit messages describing what changed
|
||||
|
||||
## 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
|
||||
@@ -1,6 +1,6 @@
|
||||
use chrono::NaiveDateTime;
|
||||
use csv::ReaderBuilder;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::fs;
|
||||
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> {
|
||||
let mut customers: BTreeMap<String, Customer> = BTreeMap::new();
|
||||
|
||||
@@ -126,3 +169,132 @@ pub fn group_by_customer(batches: &[Batch]) -> BTreeMap<String, Customer> {
|
||||
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
124
src/main.rs
124
src/main.rs
@@ -8,7 +8,7 @@ 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, Customer};
|
||||
use invoice_generator::{group_by_customer, read_csv_file_by_batch, Customer};
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
@@ -330,72 +330,84 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
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)?;
|
||||
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!(
|
||||
"Converted {} transactions",
|
||||
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!("Loaded {} transactions", 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 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 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(),
|
||||
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(),
|
||||
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);
|
||||
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 in {:?}",
|
||||
customer_count, output_dir
|
||||
"\nGenerated {} customer invoices across {} batches in {:?}",
|
||||
total_customers,
|
||||
batch_count,
|
||||
base_output_dir
|
||||
);
|
||||
}
|
||||
"db" => {
|
||||
|
||||
Reference in New Issue
Block a user