Compare commits
3 Commits
1e9af16325
...
main
| 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 chrono::NaiveDateTime;
|
||||||
use csv::ReaderBuilder;
|
use csv::ReaderBuilder;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::{BTreeMap, HashMap};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
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> {
|
pub fn group_by_customer(batches: &[Batch]) -> BTreeMap<String, Customer> {
|
||||||
let mut customers: BTreeMap<String, Customer> = BTreeMap::new();
|
let mut customers: BTreeMap<String, Customer> = BTreeMap::new();
|
||||||
|
|
||||||
@@ -126,3 +169,132 @@ pub fn group_by_customer(batches: &[Batch]) -> BTreeMap<String, Customer> {
|
|||||||
|
|
||||||
customers
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
58
src/main.rs
58
src/main.rs
@@ -8,7 +8,7 @@ use chrono::{NaiveDateTime, Utc};
|
|||||||
use config::{Config, Env};
|
use config::{Config, Env};
|
||||||
use csv::ReaderBuilder;
|
use csv::ReaderBuilder;
|
||||||
use db::{create_pool, Repository};
|
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::collections::HashMap;
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -330,28 +330,36 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
let temp_cleaned_path =
|
let temp_cleaned_path =
|
||||||
base_output_dir.join(format!("{}.temp.csv", filename.trim_end_matches(".txt")));
|
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 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);
|
let output_dir = base_output_dir.join(&batch_number);
|
||||||
|
|
||||||
fs::create_dir_all(&output_dir)?;
|
fs::create_dir_all(&output_dir)?;
|
||||||
|
|
||||||
fs::copy(input_path, output_dir.join(format!("{}.txt", batch_number)))?;
|
let csv_path = output_dir.join(format!("{}.csv", batch_number));
|
||||||
fs::rename(
|
let txt_path = output_dir.join(format!("{}.txt", batch_number));
|
||||||
&temp_cleaned_path,
|
|
||||||
output_dir.join(format!("{}.csv", batch_number)),
|
fs::copy(&temp_cleaned_path, &csv_path)?;
|
||||||
)?;
|
fs::copy(input_path, &txt_path)?;
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"Converted {} transactions",
|
"Batch {}: {} transactions",
|
||||||
fs::read_to_string(output_dir.join(format!("{}.csv", batch_number)))?
|
batch_number,
|
||||||
.lines()
|
batch.transactions.len()
|
||||||
.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 first_date = batch.transactions.first().map(|t| t.date).unwrap();
|
||||||
let last_date = batch.transactions.last().map(|t| t.date).unwrap();
|
let last_date = batch.transactions.last().map(|t| t.date).unwrap();
|
||||||
let period = format!(
|
let period = format!(
|
||||||
@@ -375,9 +383,6 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
fs::write(output_dir.join("index.html"), html)?;
|
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 {
|
for (customer_num, customer) in customers {
|
||||||
let prepared = PreparedCustomer::from_customer(customer);
|
let prepared = PreparedCustomer::from_customer(customer);
|
||||||
let customer_html = CustomerTemplate {
|
let customer_html = CustomerTemplate {
|
||||||
@@ -388,14 +393,21 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
.render()
|
.render()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let filename = format!("customer_{}.html", customer_num);
|
let customer_filename = format!("customer_{}.html", customer_num);
|
||||||
fs::write(output_dir.join(&filename), customer_html)?;
|
fs::write(output_dir.join(&customer_filename), customer_html)?;
|
||||||
println!("Generated {}", filename);
|
println!(" Generated {}", customer_filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
total_customers += index_customers.len();
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::remove_file(temp_cleaned_path)?;
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"\nGenerated {} customer invoices in {:?}",
|
"\nGenerated {} customer invoices across {} batches in {:?}",
|
||||||
customer_count, output_dir
|
total_customers,
|
||||||
|
batch_count,
|
||||||
|
base_output_dir
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
"db" => {
|
"db" => {
|
||||||
|
|||||||
Reference in New Issue
Block a user